From eb619ea5aed5e535f0aec633ce08bda69394bce2 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 22 Jun 2026 23:16:39 -0700 Subject: [PATCH 1/2] feat(browser): add dockable extracted browser window - Add browser extraction IPC, window-kind plumbing, and host management - Replace drawer overlay with dock slot/right-panel browser hosting - Add omnibox suggestions, bookmarks, bookmark bar, and history storage - Localize new browser UI strings and expand browser/panel tests --- src/main/browser/BrowserPanelManager.ts | 104 +++++- src/main/browser/browserBookmarks.test.ts | 50 +++ src/main/browser/browserBookmarks.ts | 90 ++++++ src/main/browser/browserHistory.test.ts | 84 +++++ src/main/browser/browserHistory.ts | 157 +++++++++ src/main/ipc/localHandlers.ts | 26 ++ src/main/main.ts | 111 ++++++- src/main/preload.ts | 6 + src/main/window/createMainWindow.ts | 39 ++- src/renderer/app.tsx | 46 ++- .../components/layout/BrowserDrawerShell.tsx | 160 ---------- .../components/layout/UnifiedRightPanel.tsx | 13 + src/renderer/components/ui/provider.tsx | 12 +- src/renderer/locales/de/messages.po | 19 ++ src/renderer/locales/en/messages.po | 19 ++ src/renderer/locales/es/messages.po | 19 ++ src/renderer/locales/fr/messages.po | 19 ++ src/renderer/locales/ja/messages.po | 19 ++ src/renderer/locales/ko/messages.po | 19 ++ src/renderer/locales/pl/messages.po | 19 ++ src/renderer/locales/pt-BR/messages.po | 19 ++ src/renderer/locales/ru/messages.po | 19 ++ src/renderer/locales/tr/messages.po | 19 ++ src/renderer/locales/uk/messages.po | 19 ++ src/renderer/locales/vi/messages.po | 19 ++ src/renderer/locales/zh-CN/messages.po | 19 ++ src/renderer/state/browserDockStore.ts | 21 ++ src/renderer/state/browserPanelStore.ts | 39 ++- src/renderer/state/panelStore.test.ts | 27 +- src/renderer/state/panelStore.ts | 32 +- .../views/MainView/parts/AppOverlays.tsx | 5 +- .../views/MainView/parts/BrowserOverlay.tsx | 19 -- .../MainView/parts/ProjectAuxiliaryPanel.tsx | 26 +- .../parts/BrowserPanel/BrowserDockSlot.tsx | 53 ++++ .../parts/BrowserPanel/BrowserHost.tsx | 235 ++++++++++++++ .../parts/BrowserPanel/BrowserPanel.test.tsx | 91 +++++- .../parts/BrowserPanel/BrowserPanel.tsx | 100 ++++-- .../BrowserPanel/browserWindowActions.ts | 15 + .../BrowserPanel/hooks/useBrowserSync.ts | 18 +- .../BrowserPanel/parts/BrowserBookmarkBar.tsx | 78 +++++ .../BrowserPanel/parts/BrowserOmnibox.tsx | 286 +++++++++++++++++ .../BrowserPanel/parts/BrowserTabStrip.tsx | 40 ++- .../BrowserPanel/parts/BrowserToolbar.tsx | 299 ++++++++++++------ src/shared/ipc.test.ts | 2 + src/shared/ipc/bridge.ts | 3 + src/shared/ipc/index.ts | 4 + src/shared/ipc/procedureMap.ts | 7 + src/shared/ipc/procedures/browser.ts | 75 +++++ src/shared/url.ts | 4 + 49 files changed, 2239 insertions(+), 385 deletions(-) create mode 100644 src/main/browser/browserBookmarks.test.ts create mode 100644 src/main/browser/browserBookmarks.ts create mode 100644 src/main/browser/browserHistory.test.ts create mode 100644 src/main/browser/browserHistory.ts delete mode 100644 src/renderer/components/layout/BrowserDrawerShell.tsx create mode 100644 src/renderer/state/browserDockStore.ts delete mode 100644 src/renderer/views/MainView/parts/BrowserOverlay.tsx create mode 100644 src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserDockSlot.tsx create mode 100644 src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserHost.tsx create mode 100644 src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/browserWindowActions.ts create mode 100644 src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/parts/BrowserBookmarkBar.tsx create mode 100644 src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/parts/BrowserOmnibox.tsx create mode 100644 src/shared/url.ts diff --git a/src/main/browser/BrowserPanelManager.ts b/src/main/browser/BrowserPanelManager.ts index f0402f8f..6f1c6d22 100644 --- a/src/main/browser/BrowserPanelManager.ts +++ b/src/main/browser/BrowserPanelManager.ts @@ -15,6 +15,8 @@ import { saveClipboardImageFile } from "../attachments/localFiles"; import { IPC_EVENT_CHANNELS } from "@/shared/ipc"; import { BrowserLoginCaptureCoordinator } from "./BrowserLoginCaptureCoordinator"; import { BrowserTab, resolveWebContentsById } from "./BrowserTab"; +import { BrowserHistoryStore, fetchSearchSuggestions } from "./browserHistory"; +import { BrowserBookmarkStore, type BrowserBookmark } from "./browserBookmarks"; import { PICKER_COMMIT_ORIGIN, onPickerCommit } from "./picker/pickerProtocol"; import { buildPickerScript } from "./picker/pickerScript"; @@ -46,13 +48,20 @@ type PickerPayload = title: string; }; +interface BrowserPanelManagerOptions { + isExtracted?: () => boolean; + focusExtractedWindow?: () => void; +} + export class BrowserPanelManager { private tabs: BrowserTab[] = []; private activeTabId: string | null = null; - private host: BrowserWindow | null = null; + private hosts = new Set(); private pendingPicker: PendingPicker | null = null; private unsubscribePicker: (() => void) | null = null; private persistTimer: ReturnType | null = null; + private readonly history = new BrowserHistoryStore(); + private readonly bookmarks = new BrowserBookmarkStore(); private restored = false; private pickerKeyCleanup: (() => void) | null = null; private readonly loginCoordinator = new BrowserLoginCaptureCoordinator({ @@ -60,12 +69,13 @@ export class BrowserPanelManager { closeTab: (tabId) => this.closeTab(tabId), findTab: (tabId) => this.findTab(tabId), emit: (event) => this.emit(event), - hasHostWindow: () => this.host !== null && !this.host.isDestroyed(), + hasHostWindow: () => this.hasHostWindow(), }); constructor( private readonly paths: LightcodePaths, private readonly browserUserAgent: string, + private readonly options: BrowserPanelManagerOptions = {}, ) { this.unsubscribePicker = onPickerCommit((commit) => this.onPickerCommit(commit)); } @@ -112,16 +122,21 @@ export class BrowserPanelManager { } bindHost(window: BrowserWindow): void { - this.host = window; - window.webContents.on("before-input-event", (event, input) => { + this.hosts.add(window); + const onBeforeInputEvent = (event: Electron.Event, input: Electron.Input) => { if (!this.pendingPicker || !isEscapeKeyDown(input)) return; event.preventDefault(); this.cancelPicker(); - }); + }; + window.webContents.on("before-input-event", onBeforeInputEvent); window.once("closed", () => { - this.dispose(); + this.hosts.delete(window); + try { + window.webContents.removeListener("before-input-event", onBeforeInputEvent); + } catch {} }); void this.restoreFromDisk(); + this.emitState(); } dispose(): void { @@ -138,6 +153,7 @@ export class BrowserPanelManager { } this.tabs = []; this.activeTabId = null; + this.hosts.clear(); } private clearPickerShortcut(): void { @@ -164,20 +180,40 @@ export class BrowserPanelManager { } private emit(event: BrowserEvent): void { - if (!this.host || this.host.isDestroyed()) return; - try { - this.host.webContents.send(IPC_EVENT_CHANNELS.browserEvent, event); - } catch {} + for (const host of this.hosts) { + if (host.isDestroyed()) { + this.hosts.delete(host); + continue; + } + try { + host.webContents.send(IPC_EVENT_CHANNELS.browserEvent, event); + } catch {} + } } private emitState(): void { this.emit({ type: "state", state: this.snapshot() }); } + notifyState(): void { + this.emitState(); + } + revealPanel(): void { + if (this.options.isExtracted?.()) { + this.options.focusExtractedWindow?.(); + return; + } this.emit({ type: "open-panel" }); } + private hasHostWindow(): boolean { + for (const host of this.hosts) { + if (!host.isDestroyed()) return true; + } + return false; + } + private readLinkSettings(): { linkOpenTarget: BrowserLinkOpenTarget; linkPresentationMode: BrowserLinkPresentationMode; @@ -218,7 +254,11 @@ export class BrowserPanelManager { return this.openSystemBrowser(url.toString()); } - this.emit({ type: "open-panel", mode: settings.linkPresentationMode }); + if (this.options.isExtracted?.()) { + this.options.focusExtractedWindow?.(); + } else { + this.emit({ type: "open-panel", mode: settings.linkPresentationMode }); + } void this.createTab({ url: url.toString(), activate: true }).catch(() => {}); return true; } @@ -241,9 +281,27 @@ export class BrowserPanelManager { return { tabs: this.tabs.map((t) => this.toInfo(t)), activeTabId: this.activeTabId, + extracted: this.options.isExtracted?.() === true, + bookmarks: this.bookmarks.list(), + bookmarkBarVisible: this.bookmarks.isBarVisible(), }; } + addBookmark(bookmark: BrowserBookmark): void { + this.bookmarks.add(bookmark); + this.emitState(); + } + + removeBookmark(url: string): void { + this.bookmarks.remove(url); + this.emitState(); + } + + setBookmarkBarVisible(visible: boolean): void { + this.bookmarks.setBarVisible(visible); + this.emitState(); + } + private findTab(tabId: string): BrowserTab | undefined { return this.tabs.find((t) => t.tabId === tabId); } @@ -251,10 +309,15 @@ export class BrowserPanelManager { attachWebContents(tabId: string, webContentsId: number): void { const tab = this.findTab(tabId); if (!tab) return; - if (this.host?.webContents.id === webContentsId) return; + // Reject a host window's own WebContents by id first, before resolving it. + for (const host of this.hosts) { + if (host.webContents.id === webContentsId) return; + } const wc = resolveWebContentsById(webContentsId); if (!wc) return; - if (this.host?.webContents === wc) return; + for (const host of this.hosts) { + if (host.webContents === wc) return; + } tab.attach(wc); } @@ -266,6 +329,7 @@ export class BrowserPanelManager { userAgent: this.browserUserAgent, onUpdate: (snap) => { this.emit({ type: "tab-updated", tab: { ...snap } }); + if (!snap.loading) this.history.record(snap.url, snap.title, Date.now()); this.schedulePersist(); }, onAttention: (id) => { @@ -374,11 +438,25 @@ export class BrowserPanelManager { } async clearHistory(tabId: string): Promise { + this.history.clear(); const t = this.findTab(tabId); if (!t) return; t.clearHistory(); } + async suggest(query: string): Promise<{ + history: Array<{ url: string; title: string }>; + suggestions: string[]; + }> { + const history = this.history.query(query, 6).map((e) => ({ url: e.url, title: e.title })); + const suggestions = await fetchSearchSuggestions(query, this.browserUserAgent); + return { history, suggestions }; + } + + recentHistory(limit: number): Array<{ url: string; title: string }> { + return this.history.recent(limit).map((e) => ({ url: e.url, title: e.title })); + } + async clearCookies(tabId: string): Promise { const t = this.findTab(tabId); if (!t) return; diff --git a/src/main/browser/browserBookmarks.test.ts b/src/main/browser/browserBookmarks.test.ts new file mode 100644 index 00000000..39aaf89e --- /dev/null +++ b/src/main/browser/browserBookmarks.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = new Map(); +vi.mock("../db", () => ({ + dbGetState: (k: string) => state.get(k) ?? null, + dbSetState: (k: string, v: string) => { + state.set(k, v); + }, +})); + +import { BrowserBookmarkStore } from "./browserBookmarks"; + +describe("BrowserBookmarkStore", () => { + beforeEach(() => state.clear()); + + it("adds and dedupes by url", () => { + const s = new BrowserBookmarkStore(); + s.add({ url: "https://a.com/", title: "A", createdAt: 1 }); + s.add({ url: "https://a.com/", title: "A again", createdAt: 2 }); + expect(s.list()).toHaveLength(1); + expect(s.list()[0]?.title).toBe("A"); + }); + + it("ignores non-http(s) urls", () => { + const s = new BrowserBookmarkStore(); + s.add({ url: "about:blank", title: "x", createdAt: 1 }); + expect(s.list()).toHaveLength(0); + }); + + it("removes by url", () => { + const s = new BrowserBookmarkStore(); + s.add({ url: "https://a.com/", title: "A", createdAt: 1 }); + s.remove("https://a.com/"); + expect(s.list()).toHaveLength(0); + }); + + it("persists across instances", () => { + const s = new BrowserBookmarkStore(); + s.add({ url: "https://a.com/", title: "A", createdAt: 1 }); + expect(new BrowserBookmarkStore().list()).toHaveLength(1); + }); + + it("toggles and persists bar visibility", () => { + const s = new BrowserBookmarkStore(); + expect(s.isBarVisible()).toBe(false); + s.setBarVisible(true); + expect(s.isBarVisible()).toBe(true); + expect(new BrowserBookmarkStore().isBarVisible()).toBe(true); + }); +}); diff --git a/src/main/browser/browserBookmarks.ts b/src/main/browser/browserBookmarks.ts new file mode 100644 index 00000000..7fa49322 --- /dev/null +++ b/src/main/browser/browserBookmarks.ts @@ -0,0 +1,90 @@ +import { dbGetState, dbSetState } from "../db"; + +const BOOKMARKS_KEY = "browser-bookmarks-v1"; +const BAR_VISIBLE_KEY = "browser-bookmark-bar-visible-v1"; + +export interface BrowserBookmark { + url: string; + title: string; + faviconUrl?: string; + createdAt: number; +} + +/** + * Persistent bookmarks + bookmark-bar visibility, stored as app state (same + * mechanism as tabs/history). Deduped by URL. + */ +export class BrowserBookmarkStore { + private bookmarks: BrowserBookmark[] = []; + private barVisible = false; + private loaded = false; + + private load(): void { + if (this.loaded) return; + this.loaded = true; + try { + const raw = dbGetState(BOOKMARKS_KEY); + if (raw) { + const arr = JSON.parse(raw) as BrowserBookmark[]; + if (Array.isArray(arr)) { + this.bookmarks = arr.filter( + (b): b is BrowserBookmark => + !!b && typeof b.url === "string" && typeof b.title === "string", + ); + } + } + } catch {} + try { + this.barVisible = dbGetState(BAR_VISIBLE_KEY) === "1"; + } catch {} + } + + list(): BrowserBookmark[] { + this.load(); + return this.bookmarks; + } + + isBarVisible(): boolean { + this.load(); + return this.barVisible; + } + + add(bookmark: BrowserBookmark): void { + this.load(); + if (!/^https?:\/\//i.test(bookmark.url)) return; + if (this.bookmarks.some((b) => b.url === bookmark.url)) return; + this.bookmarks = [ + ...this.bookmarks, + { + url: bookmark.url, + title: bookmark.title || bookmark.url, + createdAt: bookmark.createdAt, + ...(bookmark.faviconUrl ? { faviconUrl: bookmark.faviconUrl } : {}), + }, + ]; + this.persist(); + } + + remove(url: string): void { + this.load(); + const next = this.bookmarks.filter((b) => b.url !== url); + if (next.length === this.bookmarks.length) return; + this.bookmarks = next; + this.persist(); + } + + setBarVisible(visible: boolean): void { + this.load(); + if (this.barVisible === visible) return; + this.barVisible = visible; + try { + dbSetState(BAR_VISIBLE_KEY, visible ? "1" : "0"); + } catch {} + } + + private persist(): void { + try { + dbSetState(BOOKMARKS_KEY, JSON.stringify(this.bookmarks)); + } catch {} + } +} diff --git a/src/main/browser/browserHistory.test.ts b/src/main/browser/browserHistory.test.ts new file mode 100644 index 00000000..86ff3d63 --- /dev/null +++ b/src/main/browser/browserHistory.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = new Map(); +vi.mock("../db", () => ({ + dbGetState: (k: string) => state.get(k) ?? null, + dbSetState: (k: string, v: string) => { + state.set(k, v); + }, +})); + +import { BrowserHistoryStore, fetchSearchSuggestions } from "./browserHistory"; + +describe("BrowserHistoryStore", () => { + beforeEach(() => state.clear()); + + it("ranks frequent prefix matches first", () => { + const h = new BrowserHistoryStore(); + h.record("https://github.com/", "GitHub", 1000); + h.record("https://github.com/", "GitHub", 2000); + h.record("https://example.com/gh", "Example GH", 1500); + expect(h.query("git", 5)[0]?.url).toBe("https://github.com/"); + }); + + it("dedupes by url and keeps the latest title", () => { + const h = new BrowserHistoryStore(); + h.record("https://a.com/", "A", 1); + h.record("https://a.com/", "A2", 2); + const res = h.query("a.com", 5); + expect(res).toHaveLength(1); + expect(res[0]?.title).toBe("A2"); + }); + + it("matches both title and url substrings", () => { + const h = new BrowserHistoryStore(); + h.record("https://news.ycombinator.com/", "Hacker News", 1); + expect(h.query("hacker", 5)).toHaveLength(1); + expect(h.query("ycombinator", 5)).toHaveLength(1); + }); + + it("ignores non-http(s) urls", () => { + const h = new BrowserHistoryStore(); + h.record("about:blank", "blank", 1); + expect(h.query("blank", 5)).toHaveLength(0); + }); + + it("clears all entries", () => { + const h = new BrowserHistoryStore(); + h.record("https://a.com/", "A", 1); + h.clear(); + expect(h.query("a", 5)).toHaveLength(0); + }); +}); + +describe("fetchSearchSuggestions", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("parses the DuckDuckGo type=list format", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => ["q", ["s1", "s2"]] }), + ); + expect(await fetchSearchSuggestions("q", "ua")).toEqual(["s1", "s2"]); + }); + + it("parses an array of phrase objects", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => [{ phrase: "a" }, { phrase: "b" }] }), + ); + expect(await fetchSearchSuggestions("q", "ua")).toEqual(["a", "b"]); + }); + + it("returns [] on network failure", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + expect(await fetchSearchSuggestions("q", "ua")).toEqual([]); + }); + + it("returns [] for an empty query without fetching", async () => { + const fetchMock = vi.fn<() => Promise>(); + vi.stubGlobal("fetch", fetchMock); + expect(await fetchSearchSuggestions(" ", "ua")).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/browser/browserHistory.ts b/src/main/browser/browserHistory.ts new file mode 100644 index 00000000..01ee2a60 --- /dev/null +++ b/src/main/browser/browserHistory.ts @@ -0,0 +1,157 @@ +import { dbGetState, dbSetState } from "../db"; +import { stripScheme } from "@/shared/url"; + +const HISTORY_KEY = "browser-history-v1"; +const MAX_ENTRIES = 2000; +const PERSIST_DEBOUNCE_MS = 1000; +const SUGGEST_TIMEOUT_MS = 2500; + +export interface BrowserHistoryEntry { + url: string; + title: string; + visitCount: number; + lastVisitedAt: number; +} + +/** + * Persistent visited-URL history backing the address-bar omnibox. Stored as a + * JSON blob in app state (same mechanism the tab list uses), keyed by URL so a + * revisit bumps frequency/recency instead of duplicating. + */ +export class BrowserHistoryStore { + private entries = new Map(); + private loaded = false; + private persistTimer: ReturnType | null = null; + + private load(): void { + if (this.loaded) return; + this.loaded = true; + try { + const raw = dbGetState(HISTORY_KEY); + if (!raw) return; + const arr = JSON.parse(raw) as BrowserHistoryEntry[]; + if (!Array.isArray(arr)) return; + for (const e of arr) { + if (e && typeof e.url === "string" && typeof e.title === "string") { + this.entries.set(e.url, { + url: e.url, + title: e.title, + visitCount: typeof e.visitCount === "number" ? e.visitCount : 1, + lastVisitedAt: typeof e.lastVisitedAt === "number" ? e.lastVisitedAt : 0, + }); + } + } + } catch {} + } + + record(url: string, title: string, now: number): void { + if (!/^https?:\/\//i.test(url)) return; + this.load(); + const existing = this.entries.get(url); + if (existing) { + existing.visitCount += 1; + existing.lastVisitedAt = now; + if (title) existing.title = title; + } else { + this.entries.set(url, { url, title: title || url, visitCount: 1, lastVisitedAt: now }); + this.prune(); + } + this.schedulePersist(); + } + + query(query: string, limit: number): BrowserHistoryEntry[] { + this.load(); + const q = query.trim().toLowerCase(); + if (!q) return []; + const matched: Array<{ entry: BrowserHistoryEntry; score: number }> = []; + for (const entry of this.entries.values()) { + const url = entry.url.toLowerCase(); + const title = entry.title.toLowerCase(); + const urlIdx = url.indexOf(q); + const titleIdx = title.indexOf(q); + if (urlIdx === -1 && titleIdx === -1) continue; + // Prefix match on the URL (after the scheme) ranks highest, then frequency + // and recency. lastVisitedAt is a ms timestamp; scale it down so it acts + // as a tiebreaker rather than dominating the visit-count signal. + const startsWith = stripScheme(url).startsWith(q) || url.startsWith(q); + const score = + (startsWith ? 1000 : 0) + entry.visitCount * 10 + entry.lastVisitedAt / 1_000_000_000; + matched.push({ entry, score }); + } + matched.sort((a, b) => b.score - a.score); + return matched.slice(0, limit).map((m) => m.entry); + } + + recent(limit: number): BrowserHistoryEntry[] { + this.load(); + return [...this.entries.values()] + .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .slice(0, limit); + } + + clear(): void { + this.loaded = true; + this.entries.clear(); + this.schedulePersist(); + } + + private prune(): void { + if (this.entries.size <= MAX_ENTRIES) return; + const sorted = [...this.entries.values()].sort((a, b) => a.lastVisitedAt - b.lastVisitedAt); + const removeCount = this.entries.size - MAX_ENTRIES; + for (let i = 0; i < removeCount; i++) { + const victim = sorted[i]; + if (victim) this.entries.delete(victim.url); + } + } + + private schedulePersist(): void { + if (this.persistTimer) clearTimeout(this.persistTimer); + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + try { + dbSetState(HISTORY_KEY, JSON.stringify([...this.entries.values()])); + } catch {} + }, PERSIST_DEBOUNCE_MS); + } +} + +/** + * Fetch search-engine autocomplete suggestions from DuckDuckGo's `ac` endpoint. + * Runs in the main process (no CORS), returns [] on any failure/timeout so the + * omnibox degrades gracefully to history-only suggestions. + */ +export async function fetchSearchSuggestions(query: string, userAgent: string): Promise { + const q = query.trim(); + if (!q) return []; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SUGGEST_TIMEOUT_MS); + try { + const res = await fetch(`https://duckduckgo.com/ac/?q=${encodeURIComponent(q)}&type=list`, { + signal: controller.signal, + headers: { "User-Agent": userAgent, Accept: "application/json" }, + }); + if (!res.ok) return []; + const data: unknown = await res.json(); + // `type=list` → ["query", ["s1", "s2", ...]]; default → [{ phrase }, ...]. + if (Array.isArray(data)) { + if (Array.isArray(data[1])) { + return data[1].filter((s): s is string => typeof s === "string"); + } + return data + .map((item) => + typeof item === "string" + ? item + : item && typeof (item as { phrase?: unknown }).phrase === "string" + ? (item as { phrase: string }).phrase + : null, + ) + .filter((s): s is string => s !== null); + } + return []; + } catch { + return []; + } finally { + clearTimeout(timer); + } +} diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 83ccb29e..b8d492d6 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -63,6 +63,8 @@ interface CreateLocalIpcHandlersOptions { updatePowerSaveBlocker(): void; autoUpdater: AutoUpdaterController; onSharedSettingsChanged?(): void; + extractBrowserToWindow(): void; + injectBrowserToMain(): void; /** Relaunch the app (exposed via the relaunchApp IPC). */ requestRelaunch(): void; } @@ -343,6 +345,30 @@ export function createLocalIpcHandlers( browserCancelPicker: () => { requireBrowserPanel(options.getBrowserPanelManager).cancelPicker(); }, + browserSuggest: ({ query }) => + requireBrowserPanel(options.getBrowserPanelManager).suggest(query), + browserAddBookmark: ({ url, title, faviconUrl }) => { + requireBrowserPanel(options.getBrowserPanelManager).addBookmark({ + url, + title, + createdAt: Date.now(), + ...(faviconUrl ? { faviconUrl } : {}), + }); + }, + browserRemoveBookmark: ({ url }) => { + requireBrowserPanel(options.getBrowserPanelManager).removeBookmark(url); + }, + browserSetBookmarkBarVisible: ({ visible }) => { + requireBrowserPanel(options.getBrowserPanelManager).setBookmarkBarVisible(visible); + }, + browserRecentHistory: ({ limit }) => + requireBrowserPanel(options.getBrowserPanelManager).recentHistory(limit), + browserExtractToWindow: () => { + options.extractBrowserToWindow(); + }, + browserInjectToMain: () => { + options.injectBrowserToMain(); + }, startUsageLogin: (payload) => getUsageLoginManager( options.requireLightcodePaths, diff --git a/src/main/main.ts b/src/main/main.ts index 5c94162e..7e3ee5f0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -86,6 +86,7 @@ let lightcodePaths: LightcodePaths | null = null; let windowsJobObjectManager: WindowsJobObjectManager | null = null; let browserPanelManager: BrowserPanelManager | null = null; let browserMcpIngress: BrowserMcpIngress | null = null; +let browserExtractWindow: BrowserWindow | null = null; // Retained module-scope so the native Tray icon stays reachable from GC. let tray: TrayHandle | null = null; let isQuitting = false; @@ -145,6 +146,99 @@ function handleMainWindowClose(event: Electron.Event): void { mainWindow.hide(); } +function showAndFocusWindow(window: BrowserWindow): void { + if (window.isMinimized()) { + window.restore(); + } + if (!window.isVisible()) { + window.show(); + } + window.focus(); +} + +function focusBrowserExtractWindow(): void { + if (!browserExtractWindow || browserExtractWindow.isDestroyed()) return; + showAndFocusWindow(browserExtractWindow); +} + +function revealBrowserInMainWindow(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + showAndFocusWindow(mainWindow); + } + browserPanelManager?.notifyState(); + browserPanelManager?.revealPanel(); +} + +function createBrowserExtractWindow(): BrowserWindow { + const windowChrome = resolveWindowChromeOptions(); + const window = createMainWindow({ + title: `${getAppName(channel, isDev)} Browser`, + windowKind: "browserExtract", + boundsStateKey: "browser-extract-window-bounds", + defaultWidth: 1120, + defaultHeight: 760, + minWidth: 520, + minHeight: 420, + isDev, + channel, + preloadPath: join(__dirname, "preload.cjs"), + rendererHtmlPath: join(__dirname, "../renderer/index.html"), + appVersion: app.getVersion(), + posthogEnableDev, + posthogEnabled, + posthogHost, + posthogKey, + sentryEnabled, + windowChromeHeight: WINDOW_CHROME_HEIGHT, + browserUserAgent: chromeLikeUserAgent, + appearance: windowChrome.appearance, + sidebarTranslucency: windowChrome.sidebarTranslucency, + openDevTools: false, + ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}), + onClosed: () => { + browserExtractWindow = null; + browserPanelManager?.notifyState(); + // Closing the window — whether via the OS controls or "bring back to + // panel" (injectBrowserToMain) — returns the browser to the main window. + if (!isQuitting) { + revealBrowserInMainWindow(); + } + }, + onRendererProcessGone: (details) => { + captureMainException(new Error(`Browser renderer process gone: ${details.reason}`), { + "lightcode.feature_area": "browser", + "lightcode.process": "renderer", + }); + }, + }); + return window; +} + +function extractBrowserToWindow(): void { + if (browserExtractWindow && !browserExtractWindow.isDestroyed()) { + browserPanelManager?.notifyState(); + focusBrowserExtractWindow(); + return; + } + browserExtractWindow = createBrowserExtractWindow(); + // Bind the host (which emits state) only after `browserExtractWindow` is + // assigned, so the snapshot's `extracted` flag reads true. Otherwise the main + // window keeps showing its own browser until the next unrelated state emit. + browserPanelManager?.bindHost(browserExtractWindow); + focusBrowserExtractWindow(); +} + +function injectBrowserToMain(): void { + const window = browserExtractWindow; + if (!window || window.isDestroyed()) { + browserExtractWindow = null; + revealBrowserInMainWindow(); + return; + } + // The window's `onClosed` handler returns the browser to the main window. + window.close(); +} + const workingThreads = new Set(); const sleepInhibitor = createSleepInhibitor(); @@ -189,13 +283,7 @@ if (!hasSingleInstanceLock) { if (!mainWindow) { return; } - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - if (!mainWindow.isVisible()) { - mainWindow.show(); - } - mainWindow.focus(); + showAndFocusWindow(mainWindow); }); app.whenReady().then(async () => { @@ -278,7 +366,10 @@ if (!hasSingleInstanceLock) { }, ); - browserPanelManager = new BrowserPanelManager(lightcodePaths, chromeLikeUserAgent); + browserPanelManager = new BrowserPanelManager(lightcodePaths, chromeLikeUserAgent, { + isExtracted: () => browserExtractWindow !== null && !browserExtractWindow.isDestroyed(), + focusExtractedWindow: focusBrowserExtractWindow, + }); browserMcpIngress = new BrowserMcpIngress(); browserMcpIngress.setManagerAccessor(() => browserPanelManager); primeBrowserAllowFlags(); @@ -295,6 +386,8 @@ if (!hasSingleInstanceLock) { updatePowerSaveBlocker, autoUpdater: autoUpdaterController, onSharedSettingsChanged: primeBrowserAllowFlags, + extractBrowserToWindow, + injectBrowserToMain, requestRelaunch: () => { isQuitting = true; app.relaunch(); @@ -436,6 +529,8 @@ if (!hasSingleInstanceLock) { windowsJobObjectManager = null; browserMcpIngress?.dispose(); browserMcpIngress = null; + browserExtractWindow?.close(); + browserExtractWindow = null; browserPanelManager?.dispose(); browserPanelManager = null; sleepInhibitor.dispose(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 13c63d7c..c8178c83 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -5,6 +5,7 @@ import { IPC_EVENT_CHANNELS, type BrowserEvent, type LightcodeBridge, + type LightcodeWindowKind, type SupervisorEvent, type UpdateStatus, } from "@/shared/ipc"; @@ -44,6 +45,10 @@ function resolveChannel(): LightcodeChannel { return "stable"; } +function resolveWindowKind(): LightcodeWindowKind { + return resolveArgValue("--lc-window-kind=") === "browserExtract" ? "browserExtract" : "main"; +} + function resolveSentryEnabled(): boolean { const prefix = "--lc-sentry-enabled="; for (const arg of process.argv) { @@ -78,6 +83,7 @@ const bridge: LightcodeBridge = { arch: process.arch, chromeVersion: process.versions.chrome ?? "unknown", isDev: resolveIsDev(), + windowKind: resolveWindowKind(), channel: resolveChannel(), electronVersion: process.versions.electron ?? "unknown", nodeVersion: process.versions.node, diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 0b395c13..f07e3366 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -1,6 +1,7 @@ import { dbGetState, dbSetState } from "../db"; import { BrowserWindow, screen, type RenderProcessGoneDetails } from "electron"; import type { LightcodeChannel } from "@/shared/channel"; +import type { LightcodeWindowKind } from "@/shared/ipc"; import { installSessionPermissions } from "../browser/permissions"; import { supportsNativeWindowMaterial, syncNativeThemeForMaterial } from "./windowMaterial"; @@ -12,9 +13,9 @@ interface WindowBounds { isMaximized: boolean; } -function getSavedWindowBounds(): WindowBounds | null { +function getSavedWindowBounds(stateKey: string): WindowBounds | null { try { - const raw = dbGetState("window-bounds"); + const raw = dbGetState(stateKey); if (!raw) { return null; } @@ -42,14 +43,20 @@ function getSavedWindowBounds(): WindowBounds | null { } } -function saveWindowBounds(window: BrowserWindow): void { +function saveWindowBounds(window: BrowserWindow, stateKey: string): void { const isMaximized = window.isMaximized(); const { x, y, width, height } = window.getNormalBounds(); - dbSetState("window-bounds", JSON.stringify({ x, y, width, height, isMaximized })); + dbSetState(stateKey, JSON.stringify({ x, y, width, height, isMaximized })); } export interface CreateMainWindowOptions { title: string; + windowKind?: LightcodeWindowKind; + boundsStateKey?: string | null; + defaultWidth?: number; + defaultHeight?: number; + minWidth?: number; + minHeight?: number; isDev: boolean; channel: LightcodeChannel; preloadPath: string; @@ -70,10 +77,13 @@ export interface CreateMainWindowOptions { onClose?: (event: Electron.Event) => void; onRendererProcessGone?: (details: RenderProcessGoneDetails) => void; devServerUrl?: string; + openDevTools?: boolean; } export function createMainWindow(options: CreateMainWindowOptions): BrowserWindow { - const saved = getSavedWindowBounds(); + const boundsStateKey = + options.boundsStateKey === undefined ? "window-bounds" : options.boundsStateKey; + const saved = boundsStateKey ? getSavedWindowBounds(boundsStateKey) : null; const supportsTitleBarOverlay = process.platform === "win32" || process.platform === "linux"; const isDark = options.appearance === "dark"; // Base bg/symbol per appearance, matching styles.css and the runtime @@ -97,11 +107,11 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo const window = new BrowserWindow({ title: options.title, show: false, - width: saved?.width ?? 1460, - height: saved?.height ?? 920, + width: saved?.width ?? options.defaultWidth ?? 1460, + height: saved?.height ?? options.defaultHeight ?? 920, ...(saved?.x != null && saved?.y != null ? { x: saved.x, y: saved.y } : {}), - minWidth: 540, - minHeight: 720, + minWidth: options.minWidth ?? 540, + minHeight: options.minHeight ?? 720, backgroundColor: isMacOS || winGlassAtStart ? "#00000000" : backgroundColor, autoHideMenuBar: true, ...(isMacOS @@ -129,6 +139,7 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo additionalArguments: [ `--lc-app-version=${encodeURIComponent(options.appVersion)}`, `--lc-is-dev=${options.isDev ? "1" : "0"}`, + `--lc-window-kind=${options.windowKind ?? "main"}`, `--lc-channel=${options.channel}`, `--lc-posthog-enable-dev=${options.posthogEnableDev ? "1" : "0"}`, `--lc-posthog-enabled=${options.posthogEnabled ? "1" : "0"}`, @@ -193,7 +204,7 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo }; loadRenderer(); - if (options.isDev) { + if (options.isDev && options.openDevTools !== false) { window.webContents.openDevTools({ mode: "detach" }); } @@ -226,7 +237,9 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo if (boundsTimer) { clearTimeout(boundsTimer); } - boundsTimer = setTimeout(() => saveWindowBounds(window), 500); + if (boundsStateKey) { + boundsTimer = setTimeout(() => saveWindowBounds(window, boundsStateKey), 500); + } }; window.on("resize", debouncedSave); window.on("move", debouncedSave); @@ -236,7 +249,9 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo if (boundsTimer) { clearTimeout(boundsTimer); } - saveWindowBounds(window); + if (boundsStateKey) { + saveWindowBounds(window, boundsStateKey); + } options.onClose?.(event); }); window.on("closed", options.onClosed); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 2a4ca425..6d9d7281 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { PixelLoader } from "./components/common"; import { msg } from "@/shared/messages"; import type { RuntimeEvent } from "@/shared/contracts"; +import type { SupervisorEvent, UpdateStatus } from "@/shared/ipc"; import { readBridge } from "./bridge"; import { handleThreadStateNotification, @@ -24,6 +25,8 @@ import { AppProvider } from "./components/ui/provider"; import { ImageLightboxHost } from "./components/composer"; import { MainView } from "@/renderer/views/MainView/MainView"; import { CommandPalette } from "@/renderer/commands/CommandPalette"; +import { BrowserPanel } from "@/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel"; +import { useBrowserSync } from "@/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useBrowserSync"; import { captureAppStarted, flushProductAnalytics, @@ -40,6 +43,7 @@ import { // so that Vite HMR can tear them down before re-executing the module. let threadStateNotificationsArmed = false; +const isBrowserExtractWindow = readBridge().windowKind === "browserExtract"; // ── Runtime event rAF batcher ─────────────────────────────────── // With 6-8 concurrent streaming chats, the supervisor produces ~500 @@ -88,7 +92,7 @@ function flushPendingRuntimeEventsSync(): void { if (pendingRuntimeEvents.size > 0) flushPendingRuntimeEvents(); } -const unsubSupervisor = readBridge().onSupervisorEvent((event) => { +function handleSupervisorEvent(event: SupervisorEvent): void { if ("threadId" in event && event.threadId.startsWith("shell:")) { if (event.type === "thread-output") { useDevTerminalStore.getState().noteShellOutput(event.threadId); @@ -185,9 +189,9 @@ const unsubSupervisor = readBridge().onSupervisorEvent((event) => { store.setWslAgentStatuses(event.statuses); } } -}); +} -const unsubUpdate = readBridge().onUpdateStatus((status) => { +function handleUpdateStatus(status: UpdateStatus): void { const store = useUpdateStore.getState(); switch (status.type) { case "checking": @@ -214,17 +218,24 @@ const unsubUpdate = readBridge().onUpdateStatus((status) => { toast.danger(msg("update.error", { detail: status.message })); break; } -}); +} -const uninstallRuntimePersister = installRuntimeItemsPersister(); +// The browser-extract window renders a standalone BrowserPanel; it has no use +// for supervisor/update streams or runtime persistence, so only the main window +// wires these up (and tears them down on HMR dispose). +const mainWindowCleanups: Array<() => void> = isBrowserExtractWindow + ? [] + : [ + readBridge().onSupervisorEvent(handleSupervisorEvent), + readBridge().onUpdateStatus(handleUpdateStatus), + installRuntimeItemsPersister(), + ]; let uninstallProductAnalytics: (() => void) | null = null; let productAnalyticsStarted = false; if (import.meta.hot) { import.meta.hot.dispose(() => { - unsubSupervisor(); - unsubUpdate(); - uninstallRuntimePersister(); + for (const cleanup of mainWindowCleanups) cleanup(); if (runtimeFlushHandle !== null) { cancelAnimationFrame(runtimeFlushHandle); runtimeFlushHandle = null; @@ -237,6 +248,25 @@ if (import.meta.hot) { } export function App() { + if (isBrowserExtractWindow) { + return ; + } + return ; +} + +function BrowserExtractApp() { + useBrowserSync(); + + return ( + +
+ +
+
+ ); +} + +function MainApp() { const { initialLoading, storeHydrated, loadT0 } = useAppHydration(); useEffect(() => { diff --git a/src/renderer/components/layout/BrowserDrawerShell.tsx b/src/renderer/components/layout/BrowserDrawerShell.tsx deleted file mode 100644 index bcc17af0..00000000 --- a/src/renderer/components/layout/BrowserDrawerShell.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { - useEffect, - useState, - type CSSProperties, - type MouseEvent as ReactMouseEvent, - type ReactNode, -} from "react"; -import { useLingui } from "@lingui/react/macro"; -import { usePanelStore } from "@/renderer/state/panelStore"; -import { pushEscapeHandler } from "./overlayEscapeStack"; - -/** - * Floating shell for the in-app browser overlay. Reuses the chrome of - * LoginTerminalOverlay (rounded floating card, same margins, border, shadow) - * and hosts both presentation modes — drawer and fullscreen — on a single - * mounted element so toggling maximize transitions size/position smoothly - * instead of unmounting and replaying the entrance animation. - * - * Behavior: - * - Animation: subtle slide-in combined with fade. Works for both drawer and - * fullscreen without the jarring full-width slide of fullscreen. - * - Maximized: leaves side margins so macOS traffic lights and Windows - * titleBarOverlay controls (top-left/top-right) sit outside the panel. - * The backdrop also leaves the titlebar strip exposed so OS controls and - * window-drag region stay live. - * - Backdrop: semi-transparent scrim outside the panel — dims the underlying - * overlay and consumes pointer events. Click to dismiss. - * - Resize: left-edge drag handle adjusts width in drawer mode. While - * dragging, a full-window cursor catcher sits above the embedded webview so - * pointer events keep flowing to the host window (webview otherwise eats - * them as soon as the cursor crosses into its area). - * - Escape: routed through the shared overlay escape stack so the underlying - * overlay below this one is not also dismissed. - */ -export function BrowserDrawerShell(props: { - open: boolean; - maximized: boolean; - onExited?: () => void; - children: ReactNode; -}) { - const { open, maximized, onExited, children } = props; - const { t } = useLingui(); - const drawerWidth = usePanelStore((s) => s.browserOverlayDrawerWidth); - const setDrawerWidth = usePanelStore((s) => s.setBrowserOverlayDrawerWidth); - const [mounted, setMounted] = useState(open); - const [visible, setVisible] = useState(false); - const [isResizing, setIsResizing] = useState(false); - - useEffect(() => { - if (open) { - setMounted(true); - let inner = 0; - const outer = requestAnimationFrame(() => { - inner = requestAnimationFrame(() => setVisible(true)); - }); - return () => { - cancelAnimationFrame(outer); - if (inner) cancelAnimationFrame(inner); - }; - } - setVisible(false); - }, [open]); - - function requestClose() { - setVisible(false); - (document.activeElement as HTMLElement | null)?.blur(); - } - - useEffect(() => { - if (!open || !onExited) return; - return pushEscapeHandler(requestClose); - // requestClose closes over stable setters/refs. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, onExited]); - - function handleTransitionEnd(event: React.TransitionEvent) { - if (visible) return; - if (event.propertyName !== "opacity") return; - setMounted(false); - onExited?.(); - } - - function handleResizeStart(event: ReactMouseEvent) { - event.preventDefault(); - const startX = event.clientX; - const startWidth = drawerWidth; - document.body.style.userSelect = "none"; - document.body.style.cursor = "ew-resize"; - setIsResizing(true); - - function onMove(e: MouseEvent) { - // Handle is on the LEFT edge of a panel anchored to the RIGHT viewport - // edge; dragging left (negative clientX delta) grows the panel. - const delta = startX - e.clientX; - setDrawerWidth(startWidth + delta); - } - function onUp() { - document.body.style.userSelect = ""; - document.body.style.cursor = ""; - setIsResizing(false); - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - } - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - } - - if (!mounted) return null; - - const maximizedOverrides: CSSProperties = maximized - ? { - top: 0, - right: 0, - bottom: 0, - width: "100vw", - maxWidth: "none", - borderRadius: 0, - } - : { width: `${drawerWidth}px` }; - - const animationTransition = isResizing - ? "transform 240ms ease-out, opacity 240ms ease-out, top 200ms ease-out, right 200ms ease-out, bottom 200ms ease-out, max-width 200ms ease-out, border-radius 200ms ease-out" - : "transform 240ms ease-out, opacity 240ms ease-out, top 200ms ease-out, right 200ms ease-out, bottom 200ms ease-out, width 200ms ease-out, max-width 200ms ease-out, border-radius 200ms ease-out"; - - return ( -
-
-
- {!maximized ? ( -
- ) : null} - {children} -
- {isResizing ? ( -
- ) : null} -
- ); -} diff --git a/src/renderer/components/layout/UnifiedRightPanel.tsx b/src/renderer/components/layout/UnifiedRightPanel.tsx index 05d24bbb..1d3f7ea1 100644 --- a/src/renderer/components/layout/UnifiedRightPanel.tsx +++ b/src/renderer/components/layout/UnifiedRightPanel.tsx @@ -7,6 +7,7 @@ import { Maximize2, NotebookPen, PanelRightClose, + PictureInPicture2, TerminalSquare, } from "lucide-react"; import { useLingui } from "@lingui/react/macro"; @@ -40,6 +41,7 @@ export function UnifiedRightPanel(props: { onExpandGitToOverlay?: () => void; onExpandFilesToOverlay?: () => void; onExpandBrowserToOverlay?: () => void; + onExtractBrowserToWindow?: () => void; onOpenGit?: () => void; onOpenTerminal?: () => void; onOpenFiles?: () => void; @@ -67,6 +69,7 @@ export function UnifiedRightPanel(props: { onExpandGitToOverlay, onExpandFilesToOverlay, onExpandBrowserToOverlay, + onExtractBrowserToWindow, onOpenGit, onOpenTerminal, onOpenFiles, @@ -134,6 +137,16 @@ export function UnifiedRightPanel(props: { )} + {activeTab === "browser" && onExtractBrowserToWindow && ( + + )} {activeTab === "usage" ? usageHeaderActions : null}
{showTerminalTab ? ( diff --git a/src/renderer/components/ui/provider.tsx b/src/renderer/components/ui/provider.tsx index f09fb0e2..ca3d84bc 100644 --- a/src/renderer/components/ui/provider.tsx +++ b/src/renderer/components/ui/provider.tsx @@ -73,11 +73,15 @@ function ToastAction({ actionProps, actionLabel, isCopyAction }: ToastActionProp ); } -export function AppProvider(props: { children: ReactNode; contentReady?: boolean }) { +export function AppProvider(props: { + children: ReactNode; + contentReady?: boolean; + syncWindowChrome?: boolean; +}) { // `contentReady` gates the glass material: the window stays opaque through // loading and only goes translucent once the main content is mounted, so the // app never shows a bare translucent window mid-load. - const { children, contentReady = false } = props; + const { children, contentReady = false, syncWindowChrome = true } = props; const themeMode = useSharedSettings((state) => state.themeMode); const themePreset = useSharedSettings((state) => state.themePreset); const locale = useSharedSettings((state) => state.locale); @@ -154,7 +158,7 @@ export function AppProvider(props: { children: ReactNode; contentReady?: boolean }, [glassEnabled, contentReady]); useEffect(() => { - if (typeof window === "undefined" || !("lightcode" in window)) { + if (!syncWindowChrome || typeof window === "undefined" || !("lightcode" in window)) { return; } @@ -180,7 +184,7 @@ export function AppProvider(props: { children: ReactNode; contentReady?: boolean captureRendererException(error, { featureArea: "window-chrome" }); // Keep renderer boot resilient if Electron rejects a color value. }); - }, [appearance, glassEnabled, contentReady]); + }, [appearance, glassEnabled, contentReady, syncWindowChrome]); // User-tuned sidebar frosting (Appearance slider): override the glass tint // alpha for the active appearance. No-op on platforms without a native blur diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index e529261d..2a3d642a 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -2354,6 +2354,11 @@ msgstr "Externer Anbieter" msgid "Extra High" msgstr "Extra hoch" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Browser auskoppeln" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2847,6 +2852,10 @@ msgstr "Übernahme der globalen Einstellung (derzeit {0})." msgid "Initialize Repository" msgstr "Repository initialisieren" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Browser einfügen" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Eingangspegel" @@ -3409,6 +3418,15 @@ msgstr "Meistgenutzte Reasoning-Stufe" msgid "Move" msgstr "Bewegen" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Browser zurück ins Hauptfenster verschieben" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Browser in Fenster verschieben" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Änderungen in einen neuen Arbeitsbaum verschieben" @@ -5813,6 +5831,7 @@ msgstr "Theme" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Denkt …" diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index a7f15f0d..eaa458de 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -2331,6 +2331,11 @@ msgstr "External provider" msgid "Extra High" msgstr "Extra High" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Extract browser" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2817,6 +2822,10 @@ msgstr "Inheriting the global setting (currently {0})." msgid "Initialize Repository" msgstr "Initialize Repository" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Inject browser" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Input level" @@ -3377,6 +3386,15 @@ msgstr "Most used reasoning" msgid "Move" msgstr "Move" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Move browser back to main window" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Move browser to window" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Move changes to a new worktree" @@ -5756,6 +5774,7 @@ msgstr "Theme" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Thinking" diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index babcbff8..aac329a0 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -2346,6 +2346,11 @@ msgstr "Proveedor externo" msgid "Extra High" msgstr "Extra alto" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Extraer navegador" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2838,6 +2843,10 @@ msgstr "Heredando el ajuste global (actualmente {0})." msgid "Initialize Repository" msgstr "Inicializar repositorio" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Insertar navegador" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Nivel de entrada" @@ -3402,6 +3411,15 @@ msgstr "Razonamiento más usado" msgid "Move" msgstr "Mover" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Mover navegador de vuelta a la ventana principal" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Mover navegador a una ventana" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Mover cambios a un nuevo worktree" @@ -5799,6 +5817,7 @@ msgstr "Tema" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Pensando" diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 69d6f97e..6f30176f 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -2354,6 +2354,11 @@ msgstr "Fournisseur externe" msgid "Extra High" msgstr "Très élevé" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Extraire le navigateur" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2847,6 +2852,10 @@ msgstr "Hérité du paramètre global (actuellement {0})." msgid "Initialize Repository" msgstr "Initialiser le dépôt" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Réintégrer le navigateur" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Niveau d'entrée" @@ -3410,6 +3419,15 @@ msgstr "Raisonnement le plus utilisé" msgid "Move" msgstr "Déplacer" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Replacer le navigateur dans la fenêtre principale" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Déplacer le navigateur vers une fenêtre" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Déplacer les modifications vers un nouvel arbre de travail" @@ -5812,6 +5830,7 @@ msgstr "Thème" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Réflexion" diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index 90af3082..070d3985 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -2317,6 +2317,11 @@ msgstr "外部プロバイダー" msgid "Extra High" msgstr "エクストラハイ" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "ブラウザを切り離す" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2794,6 +2799,10 @@ msgstr "グローバル設定 (現在{0}) を継承します。" msgid "Initialize Repository" msgstr "リポジトリの初期化" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "ブラウザを戻す" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "入力レベル" @@ -3349,6 +3358,15 @@ msgstr "よく使う推論レベル" msgid "Move" msgstr "移動" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "ブラウザーをメインウィンドウに戻す" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "ブラウザーをウィンドウに移動" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "変更を新しいワークツリーに移動する" @@ -5714,6 +5732,7 @@ msgstr "テーマ" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "思考中" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index df55fef8..445dc2eb 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -2317,6 +2317,11 @@ msgstr "외부 제공자" msgid "Extra High" msgstr "매우 높음" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "브라우저 분리" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2794,6 +2799,10 @@ msgstr "전역 설정 상속(현재 {0})" msgid "Initialize Repository" msgstr "저장소 초기화" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "브라우저 되돌리기" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "입력 레벨" @@ -3350,6 +3359,15 @@ msgstr "가장 많이 사용한 추론 강도" msgid "Move" msgstr "이동" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "브라우저를 메인 창으로 다시 이동" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "브라우저를 창으로 이동" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "변경 사항을 새 작업 트리로 이동" @@ -5712,6 +5730,7 @@ msgstr "테마" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "생각 중" diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index ecf49630..16aad4ce 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -2344,6 +2344,11 @@ msgstr "Zewnętrzny dostawca" msgid "Extra High" msgstr "Bardzo wysoka" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Wyodrębnij przeglądarkę" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2838,6 +2843,10 @@ msgstr "Dziedziczenie ustawienia globalnego (obecnie {0})." msgid "Initialize Repository" msgstr "Zainicjuj repozytorium" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Wstaw przeglądarkę" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Poziom wejściowy" @@ -3400,6 +3409,15 @@ msgstr "Najczęściej używane rozumowanie" msgid "Move" msgstr "Przenieś" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Przenieś przeglądarkę z powrotem do głównego okna" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Przenieś przeglądarkę do okna" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Przenieś zmiany do nowego drzewa roboczego" @@ -5791,6 +5809,7 @@ msgstr "Motyw" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Myślenie" diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index fad7843b..f5fbebb3 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -2348,6 +2348,11 @@ msgstr "Provedor externo" msgid "Extra High" msgstr "Extra Alto" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Extrair navegador" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2840,6 +2845,10 @@ msgstr "Herdando a configuração global (atualmente {0})." msgid "Initialize Repository" msgstr "Inicializar repositório" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Injetar navegador" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Nível de entrada" @@ -3403,6 +3412,15 @@ msgstr "Raciocínio mais usado" msgid "Move" msgstr "Mover" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Mover navegador de volta para a janela principal" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Mover navegador para uma janela" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Mover alterações para uma nova árvore de trabalho" @@ -5797,6 +5815,7 @@ msgstr "Tema" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Pensando" diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 34686cbd..67f87fc0 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -2345,6 +2345,11 @@ msgstr "Внешний провайдер" msgid "Extra High" msgstr "Очень высокий" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Вынести браузер" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2837,6 +2842,10 @@ msgstr "Наследуется глобальная настройка (сейч msgid "Initialize Repository" msgstr "Инициализировать репозиторий" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Вернуть браузер" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Уровень входного сигнала" @@ -3400,6 +3409,15 @@ msgstr "Самый используемый уровень рассуждени msgid "Move" msgstr "Переместить" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Вернуть браузер в главное окно" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Переместить браузер в окно" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Переместить изменения в новый worktree" @@ -5788,6 +5806,7 @@ msgstr "Тема" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Размышление" diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 2215731b..7ffafcfc 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -2345,6 +2345,11 @@ msgstr "Harici sağlayıcı" msgid "Extra High" msgstr "Ekstra Yüksek" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Tarayıcıyı ayır" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2836,6 +2841,10 @@ msgstr "Genel ayar devralınıyor (şu anda {0})." msgid "Initialize Repository" msgstr "Depoyu Başlat" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Tarayıcıyı geri ekle" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Giriş seviyesi" @@ -3398,6 +3407,15 @@ msgstr "En çok kullanılan akıl yürütme" msgid "Move" msgstr "Taşı" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Tarayıcıyı ana pencereye geri taşı" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Tarayıcıyı pencereye taşı" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Değişiklikleri yeni bir çalışma ağacına taşı" @@ -5790,6 +5808,7 @@ msgstr "Tema" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Düşünme" diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index 7a3686d8..5ba2cd8b 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -2344,6 +2344,11 @@ msgstr "Зовнішній провайдер" msgid "Extra High" msgstr "Дуже високий" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Винести браузер" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2837,6 +2842,10 @@ msgstr "Успадковується глобальне налаштування msgid "Initialize Repository" msgstr "Ініціалізувати репозиторій" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Повернути браузер" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Рівень вхідного сигналу" @@ -3400,6 +3409,15 @@ msgstr "Найуживаніший рівень міркувань" msgid "Move" msgstr "Перемістити" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Повернути браузер у головне вікно" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Перемістити браузер у вікно" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Перемістити зміни в новий worktree" @@ -5787,6 +5805,7 @@ msgstr "Тема" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Міркування" diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index 208b2f9a..e8ddcf74 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -2342,6 +2342,11 @@ msgstr "Nhà cung cấp bên ngoài" msgid "Extra High" msgstr "Cực cao" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "Tách trình duyệt" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2830,6 +2835,10 @@ msgstr "Kế thừa cài đặt chung (hiện tại {0})." msgid "Initialize Repository" msgstr "Khởi tạo kho lưu trữ" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "Đưa trình duyệt về lại" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "Mức đầu vào" @@ -3392,6 +3401,15 @@ msgstr "Mức suy luận dùng nhiều nhất" msgid "Move" msgstr "Di chuyển" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "Chuyển trình duyệt về cửa sổ chính" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "Chuyển trình duyệt sang cửa sổ" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "Di chuyển các thay đổi sang một cây làm việc mới" @@ -5780,6 +5798,7 @@ msgstr "Chủ đề" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "Đang suy nghĩ" diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index 65f09f6d..3254f03c 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -2314,6 +2314,11 @@ msgstr "外部服务商" msgid "Extra High" msgstr "超高" +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Extract browser" +#~ msgstr "分离浏览器" + #. placeholder {0}: sourceAgent?.label ?? thread.agentKind #: src/renderer/components/thread/ContinueInProviderDialog.tsx #: src/renderer/views/MainView/parts/AppContent/parts/Thread/parts/ContinueInProviderDialog.tsx @@ -2788,6 +2793,10 @@ msgstr "继承全局设置(当前为{0})。" msgid "Initialize Repository" msgstr "初始化存储库" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +#~ msgid "Inject browser" +#~ msgstr "移回浏览器" + #: src/renderer/views/SettingsOverlay/parts/AudioSettings.tsx msgid "Input level" msgstr "输入电平" @@ -3342,6 +3351,15 @@ msgstr "最常用思考级别" msgid "Move" msgstr "移动" +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser back to main window" +msgstr "将浏览器移回主窗口" + +#: src/renderer/components/layout/UnifiedRightPanel.tsx +#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +msgid "Move browser to window" +msgstr "将浏览器移到窗口" + #: src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx msgid "Move changes to a new worktree" msgstr "将更改移至新工作树" @@ -5701,6 +5719,7 @@ msgstr "主题" #: src/renderer/components/common/EffortContextMenu/EffortContextMenu.tsx #: src/renderer/components/thread/ChatPane/parts/items/Reasoning.tsx +#: src/renderer/components/thread/ThreadComposer.tsx msgid "Thinking" msgstr "思考" diff --git a/src/renderer/state/browserDockStore.ts b/src/renderer/state/browserDockStore.ts new file mode 100644 index 00000000..76fff430 --- /dev/null +++ b/src/renderer/state/browserDockStore.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; + +/** + * Bridges the right-panel browser "dock slot" (a placeholder rendered inside + * {@link ProjectAuxiliaryPanel}) to the top-level {@link BrowserHost}. + * + * The browser webview is mounted once in a `document.body` portal so it never + * unmounts (and thus never reloads the page) as it moves between docked, + * floating, and fullscreen presentations. While docked it must visually sit + * over the panel's browser tab area, so the slot publishes its DOM element here + * and the host tracks its rect imperatively. + */ +interface BrowserDockState { + slotEl: HTMLElement | null; + setSlotEl: (el: HTMLElement | null) => void; +} + +export const useBrowserDockStore = create((set) => ({ + slotEl: null, + setSlotEl: (el) => set((s) => (s.slotEl === el ? {} : { slotEl: el })), +})); diff --git a/src/renderer/state/browserPanelStore.ts b/src/renderer/state/browserPanelStore.ts index c4c6adc5..0df2b4fa 100644 --- a/src/renderer/state/browserPanelStore.ts +++ b/src/renderer/state/browserPanelStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { UsageLoginConfirmationRequest, UsageLoginDeviceCode } from "@/shared/contracts"; -import type { BrowserState, BrowserTabInfo } from "@/shared/ipc"; +import type { BrowserBookmarkInfo, BrowserState, BrowserTabInfo } from "@/shared/ipc"; export interface PendingPickerAttachment { attachmentPath: string; @@ -15,6 +15,9 @@ export interface PendingPickerAttachment { interface BrowserPanelState { tabs: BrowserTabInfo[]; activeTabId: string | null; + extracted: boolean; + bookmarks: BrowserBookmarkInfo[]; + bookmarkBarVisible: boolean; pickerActive: boolean; attentionTabId: string | null; pendingPickerAttachment: PendingPickerAttachment | null; @@ -35,6 +38,9 @@ interface BrowserPanelState { export const useBrowserPanelStore = create((set) => ({ tabs: [], activeTabId: null, + extracted: false, + bookmarks: [], + bookmarkBarVisible: false, pickerActive: false, attentionTabId: null, pendingPickerAttachment: null, @@ -43,8 +49,25 @@ export const useBrowserPanelStore = create((set) => ({ setState: (state) => set((s) => { - if (s.activeTabId === state.activeTabId && tabsEqual(s.tabs, state.tabs)) return {}; - return { tabs: state.tabs, activeTabId: state.activeTabId }; + const extracted = state.extracted === true; + const bookmarks = state.bookmarks ?? []; + const bookmarkBarVisible = state.bookmarkBarVisible === true; + if ( + s.activeTabId === state.activeTabId && + s.extracted === extracted && + s.bookmarkBarVisible === bookmarkBarVisible && + bookmarksEqual(s.bookmarks, bookmarks) && + tabsEqual(s.tabs, state.tabs) + ) { + return {}; + } + return { + tabs: state.tabs, + activeTabId: state.activeTabId, + extracted, + bookmarks, + bookmarkBarVisible, + }; }), upsertTab: (tab) => set((s) => { @@ -82,6 +105,16 @@ function tabsEqual(a: BrowserTabInfo[], b: BrowserTabInfo[]): boolean { return a.length === b.length && a.every((tab, i) => tabInfoEqual(tab, b[i]!)); } +function bookmarksEqual(a: BrowserBookmarkInfo[], b: BrowserBookmarkInfo[]): boolean { + return ( + a.length === b.length && + a.every( + (bm, i) => + bm.url === b[i]!.url && bm.title === b[i]!.title && bm.faviconUrl === b[i]!.faviconUrl, + ) + ); +} + function tabInfoEqual(a: BrowserTabInfo, b: BrowserTabInfo): boolean { return ( a.tabId === b.tabId && diff --git a/src/renderer/state/panelStore.test.ts b/src/renderer/state/panelStore.test.ts index abd32cf0..fb3e144d 100644 --- a/src/renderer/state/panelStore.test.ts +++ b/src/renderer/state/panelStore.test.ts @@ -128,24 +128,37 @@ describe("browserOverlayMaximized lifecycle", () => { expect(usePanelStore.getState().browserOverlayMaximized).toBe(false); }); - it("is reset when the browser panel is closed entirely", () => { + it("survives hiding the right-panel browser (overlay is independent)", () => { const { setBrowserOverlayOpen, setBrowserOverlayMaximized, setBrowserPanelOpen } = usePanelStore.getState(); + setBrowserPanelOpen(true); setBrowserOverlayOpen(true); setBrowserOverlayMaximized(true); + // Hiding the docked panel must not tear down a maximized overlay, otherwise + // the fullscreen page would vanish when the right panel is hidden. setBrowserPanelOpen(false); - expect(usePanelStore.getState().browserOverlayMaximized).toBe(false); - expect(usePanelStore.getState().browserOverlayOpen).toBe(false); + expect(usePanelStore.getState().browserPanelOpen).toBe(false); + expect(usePanelStore.getState().browserOverlayMaximized).toBe(true); + expect(usePanelStore.getState().browserOverlayOpen).toBe(true); }); - it("is reset by closeAllPanels", () => { - const { setBrowserOverlayOpen, setBrowserOverlayMaximized, closeAllPanels } = - usePanelStore.getState(); + it("survives closeAllPanels (e.g. the narrow-viewport right-panel auto-hide)", () => { + const { + setBrowserPanelOpen, + setBrowserOverlayOpen, + setBrowserOverlayMaximized, + closeAllPanels, + } = usePanelStore.getState(); + setBrowserPanelOpen(true); setBrowserOverlayOpen(true); setBrowserOverlayMaximized(true); + // closeAllPanels backs the right-panel auto-hide on resize; it must close + // the docked panel but leave the standalone browser overlay intact. closeAllPanels(); - expect(usePanelStore.getState().browserOverlayMaximized).toBe(false); + expect(usePanelStore.getState().browserPanelOpen).toBe(false); + expect(usePanelStore.getState().browserOverlayOpen).toBe(true); + expect(usePanelStore.getState().browserOverlayMaximized).toBe(true); }); }); diff --git a/src/renderer/state/panelStore.ts b/src/renderer/state/panelStore.ts index 22325c38..8a966173 100644 --- a/src/renderer/state/panelStore.ts +++ b/src/renderer/state/panelStore.ts @@ -191,19 +191,18 @@ export const usePanelStore = create((set) => ({ }), setRightPanelTab: (tab) => set((state) => (state.rightPanelTab === tab ? {} : { rightPanelTab: tab })), + // Toggling the docked right-panel browser is independent of the floating + // overlay (drawer/fullscreen): hiding the panel must NOT tear down an active + // overlay, otherwise maximizing the browser and then hiding the right panel + // would make the fullscreen page vanish. Callers that genuinely want to + // dismiss both (e.g. the last tab closing) close the overlay explicitly. setBrowserPanelOpen: (v) => - set((state) => - state.browserPanelOpen === v && (v || !state.browserOverlayOpen) - ? {} - : { - browserPanelOpen: v, - ...(v ? {} : { browserOverlayOpen: false, browserOverlayMaximized: false }), - }, - ), + set((state) => (state.browserPanelOpen === v ? {} : { browserPanelOpen: v })), // NOTE: overlay state is intentionally independent of the right-panel - // browser. Opening the overlay does NOT enable the right-panel browser tab, - // and closing the overlay leaves the right panel in whatever state the user - // had it. Maximized resets on close so the next open lands in drawer mode. + // browser in both directions. Opening the overlay does NOT enable the + // right-panel browser tab, and closing the overlay leaves the right panel in + // whatever state the user had it. Maximized resets on close so the next open + // lands in drawer mode. setBrowserOverlayOpen: (v) => set((state) => state.browserOverlayOpen === v @@ -279,14 +278,17 @@ export const usePanelStore = create((set) => ({ closeAllPanels: () => { localStorage.removeItem(STORAGE_KEY); set((state) => { + // The floating browser overlay (drawer/fullscreen) is intentionally NOT + // touched here: it is a standalone surface with its own close controls. + // Closing the docked right panel — including the narrow-viewport auto-hide + // that fires when the window shrinks — must not tear it down, otherwise a + // maximized browser vanishes the moment the right panel auto-closes. if ( state.gitReviewContext === null && state.filesPanelContext === null && !state.browserPanelOpen && !state.usagePanelOpen && - !state.notesPanelOpen && - !state.browserOverlayOpen && - !state.browserOverlayMaximized + !state.notesPanelOpen ) { return {}; } @@ -296,8 +298,6 @@ export const usePanelStore = create((set) => ({ browserPanelOpen: false, usagePanelOpen: false, notesPanelOpen: false, - browserOverlayOpen: false, - browserOverlayMaximized: false, }; }); }, diff --git a/src/renderer/views/MainView/parts/AppOverlays.tsx b/src/renderer/views/MainView/parts/AppOverlays.tsx index 3a5fd163..18d364f6 100644 --- a/src/renderer/views/MainView/parts/AppOverlays.tsx +++ b/src/renderer/views/MainView/parts/AppOverlays.tsx @@ -36,7 +36,7 @@ import { Button } from "@/renderer/components/common/Button"; import { useBrowserPanelStore } from "@/renderer/state/browserPanelStore"; import type { UsageLoginConfirmationAction } from "@/shared/contracts"; import { WelcomeOverlay } from "@/renderer/views/WelcomeOverlay"; -import { BrowserOverlay } from "@/renderer/views/MainView/parts/BrowserOverlay"; +import { BrowserHost } from "@/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserHost"; import { LoginTerminalOverlay } from "@/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay"; import { CreateProjectModal } from "@/renderer/views/MainView/parts/CreateProject/CreateProjectModal"; import { CloneProjectModal } from "@/renderer/views/MainView/parts/CreateProject/CloneProjectModal"; @@ -60,7 +60,6 @@ export function AppOverlays() { ? projects.find((p) => p.id === prReviewContext.projectId) : undefined; const prReviewVisible = !!prReviewContext && !!prReviewProject; - const browserOverlayOpen = usePanelStore((s) => s.browserOverlayOpen); return ( <> @@ -180,7 +179,7 @@ export function AppOverlays() { ) : null} - + diff --git a/src/renderer/views/MainView/parts/BrowserOverlay.tsx b/src/renderer/views/MainView/parts/BrowserOverlay.tsx deleted file mode 100644 index 887a92f1..00000000 --- a/src/renderer/views/MainView/parts/BrowserOverlay.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { usePanelStore } from "@/renderer/state/panelStore"; -import { BrowserDrawerShell } from "@/renderer/components/layout/BrowserDrawerShell"; -import { BrowserPanel } from "./RightPanel/parts/BrowserPanel/BrowserPanel"; - -export function BrowserOverlay(props: { open: boolean }) { - const { open } = props; - const maximized = usePanelStore((s) => s.browserOverlayMaximized); - const setBrowserOverlayOpen = usePanelStore((s) => s.setBrowserOverlayOpen); - - return ( - setBrowserOverlayOpen(false)} - > - - - ); -} diff --git a/src/renderer/views/MainView/parts/ProjectAuxiliaryPanel.tsx b/src/renderer/views/MainView/parts/ProjectAuxiliaryPanel.tsx index 6e77afed..79bf40fa 100644 --- a/src/renderer/views/MainView/parts/ProjectAuxiliaryPanel.tsx +++ b/src/renderer/views/MainView/parts/ProjectAuxiliaryPanel.tsx @@ -2,7 +2,11 @@ import { useRef } from "react"; import { useLingui } from "@lingui/react/macro"; import type { Project } from "@/shared/contracts"; import { isHomeProjectId } from "@/shared/homeScope"; -import { BrowserPanel } from "@/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel"; +import { BrowserDockSlot } from "@/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserDockSlot"; +import { + extractBrowserToWindow, + injectBrowserToMain, +} from "@/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/browserWindowActions"; import { DevTerminalPanel } from "@/renderer/views/MainView/parts/RightPanel/parts/DevTerminalPanel/DevTerminalPanel"; import { UnifiedRightPanel, @@ -13,6 +17,7 @@ import { NotesPanel } from "@/renderer/views/MainView/parts/RightPanel/parts/Not import { UsagePanel } from "@/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/UsagePanel"; import { UsagePanelHeaderActions } from "@/renderer/views/MainView/parts/RightPanel/parts/UsagePanel/parts/UsagePanelHeaderActions"; import { useAppStore } from "@/renderer/state/appStore"; +import { useBrowserPanelStore } from "@/renderer/state/browserPanelStore"; import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { useFileEditorStore, type FileEditorRootContext } from "@/renderer/state/fileEditorStore"; import { @@ -73,7 +78,7 @@ export function ProjectAuxiliaryPanel(props: { includeTerminal: boolean }) { const rightPanelTab = usePanelStore((s) => s.rightPanelTab); const setRightPanelTab = usePanelStore((s) => s.setRightPanelTab); const browserPanelOpen = usePanelStore((s) => s.browserPanelOpen); - const browserOverlayOpen = usePanelStore((s) => s.browserOverlayOpen); + const browserExtracted = useBrowserPanelStore((s) => s.extracted); const usagePanelOpen = usePanelStore((s) => s.usagePanelOpen); const setUsagePanelOpen = usePanelStore((s) => s.setUsagePanelOpen); const notesPanelOpen = usePanelStore((s) => s.notesPanelOpen); @@ -195,7 +200,7 @@ export function ProjectAuxiliaryPanel(props: { includeTerminal: boolean }) { const renderTerminalContent = props.includeTerminal && terminalOpen; const renderGitContent = gitPanelOpen; const renderFilesContent = filesPanelOpen; - const renderBrowserContent = browserPanelOpen && !browserOverlayOpen; + const renderBrowserContent = browserPanelOpen; const renderUsageContent = usagePanelOpen; const renderNotesContent = notesPanelOpen && notesProjectId !== undefined; @@ -218,7 +223,15 @@ export function ProjectAuxiliaryPanel(props: { includeTerminal: boolean }) { ) : undefined } - browserContent={renderBrowserContent ? : undefined} + browserContent={ + renderBrowserContent ? ( + + ) : undefined + } usageContent={renderUsageContent ? : undefined} notesContent={ renderNotesContent && notesProjectId ? ( @@ -239,10 +252,15 @@ export function ProjectAuxiliaryPanel(props: { includeTerminal: boolean }) { setBrowserOverlayMaximized(true); setBrowserOverlayOpen(true); }} + onExtractBrowserToWindow={extractBrowserToWindow} onOpenGit={handleOpenGit} onOpenFiles={handleOpenFiles} {...(props.includeTerminal ? { onOpenTerminal: handleOpenTerminal } : {})} onOpenBrowser={() => { + if (browserExtracted) { + extractBrowserToWindow(); + return; + } setBrowserPanelOpen(true); setRightPanelTab("browser"); }} diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserDockSlot.tsx b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserDockSlot.tsx new file mode 100644 index 00000000..d54dbf52 --- /dev/null +++ b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserDockSlot.tsx @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { Trans } from "@lingui/react/macro"; +import { ExternalLink, PictureInPicture2 } from "lucide-react"; +import { useBrowserDockStore } from "@/renderer/state/browserDockStore"; + +/** + * Right-panel browser tab content. The browser webview itself is rendered by + * {@link BrowserHost} in a body portal; this component only marks the area it + * should dock over (publishing the element to {@link useBrowserDockStore}), or + * shows a placeholder while the browser lives in a separate window. + */ +export function BrowserDockSlot(props: { + extracted: boolean; + onBringBack: () => void; + onFocusWindow: () => void; +}) { + const setSlotEl = useBrowserDockStore((s) => s.setSlotEl); + const slotRef = useCallback((el: HTMLDivElement | null) => setSlotEl(el), [setSlotEl]); + + if (props.extracted) { + return ( +
+ +
+ Browser is open in a separate window +
+

+ Bring it back into this panel, or jump to the window it’s running in. +

+
+ + +
+
+ ); + } + + return
; +} diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserHost.tsx b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserHost.tsx new file mode 100644 index 00000000..aa6a3011 --- /dev/null +++ b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserHost.tsx @@ -0,0 +1,235 @@ +import { + memo, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { createPortal } from "react-dom"; +import { useLingui } from "@lingui/react/macro"; +import { usePanelStore } from "@/renderer/state/panelStore"; +import { useBrowserPanelStore } from "@/renderer/state/browserPanelStore"; +import { useBrowserDockStore } from "@/renderer/state/browserDockStore"; +import { pushEscapeHandler } from "@/renderer/components/layout/overlayEscapeStack"; +import { BrowserPanel } from "./BrowserPanel"; + +const MemoBrowserPanel = memo(BrowserPanel); + +type BrowserHostMode = "hidden" | "docked" | "drawer" | "fullscreen"; + +/** + * Mounts the in-app browser exactly once, in a `document.body` portal, and + * repositions it per presentation mode (docked over the right-panel slot, + * floating drawer, or fullscreen). Keeping a single mounted instance is what + * lets the live page survive every docked↔drawer↔fullscreen transition — each + * `` owns its own guest WebContents, so remounting would reload it. + * + * Rendering from the body (rather than inside the right panel) is also required + * for correctness: the panel lives under a `will-change-transform` ancestor + * (AsideSlot) and inside a `z-index` stacking context (UnifiedRightPanel tab + * layer), both of which would clip a nested `position: fixed` overlay. + * + * Positioning is applied imperatively so per-frame rect tracking never + * re-renders the embedded webview. + */ +export function BrowserHost() { + const { t } = useLingui(); + const browserPanelOpen = usePanelStore((s) => s.browserPanelOpen); + const browserOverlayOpen = usePanelStore((s) => s.browserOverlayOpen); + const browserOverlayMaximized = usePanelStore((s) => s.browserOverlayMaximized); + const drawerWidth = usePanelStore((s) => s.browserOverlayDrawerWidth); + const setDrawerWidth = usePanelStore((s) => s.setBrowserOverlayDrawerWidth); + const rightPanelTab = usePanelStore((s) => s.rightPanelTab); + const setBrowserOverlayOpen = usePanelStore((s) => s.setBrowserOverlayOpen); + const setBrowserOverlayMaximized = usePanelStore((s) => s.setBrowserOverlayMaximized); + const setRightPanelTab = usePanelStore((s) => s.setRightPanelTab); + const extracted = useBrowserPanelStore((s) => s.extracted); + + const mode: BrowserHostMode = extracted + ? "hidden" + : browserOverlayOpen + ? browserOverlayMaximized + ? "fullscreen" + : "drawer" + : browserPanelOpen + ? "docked" + : "hidden"; + + const wrapperRef = useRef(null); + const [isResizing, setIsResizing] = useState(false); + + const dockedVisible = rightPanelTab === "browser"; + + // Position imperatively for every mode so leftover inline styles never fight + // the next mode's layout. While docked, track the panel slot's rect each + // frame — sidebar collapse / panel resize can move it without a React render. + useLayoutEffect(() => { + const w = wrapperRef.current; + if (!w) return; + if (mode === "fullscreen") { + Object.assign(w.style, { top: "0px", left: "0px", right: "0px", bottom: "0px" }); + w.style.width = ""; + w.style.height = ""; + w.style.maxWidth = ""; + // Clear the docked z-index override so the fullscreen class (z-80) wins. + w.style.zIndex = ""; + return; + } + if (mode === "drawer") { + Object.assign(w.style, { + top: "2rem", + right: "2rem", + bottom: "2rem", + left: "auto", + width: `${drawerWidth}px`, + maxWidth: "calc(100vw - 4rem)", + }); + w.style.height = ""; + // Clear the docked z-index override so the drawer class (z-60) wins. + w.style.zIndex = ""; + return; + } + // docked + if (!dockedVisible) return; + let raf = 0; + let last = ""; + let lastOverlay: boolean | null = null; + const measure = () => { + const el = useBrowserDockStore.getState().slotEl; + if (!el) return; + // On narrow viewports the right panel that hosts the slot floats as a + // fixed, opaque overlay (z-50). The webview is body-portaled at z-30, so + // it would paint *behind* that panel. Detect the overlay from the slot's + // own ancestry — its containing panel