Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "roxy",
"version": "0.0.83",
"version": "0.0.84",
"description": "Roxy — an open-source AI coding agent for engineers.",
"main": "./out/main/index.js",
"author": "Roxy (https://github.com/roxy-gg/roxy)",
Expand Down Expand Up @@ -36,10 +36,11 @@
"icons:providers": "node script/copy-provider-icons.mjs",
"smoke:shared": "esbuild test/shared.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/shared.cjs && node test/.out/shared.cjs",
"smoke:app": "esbuild test/smoke.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/smoke.cjs && electron test/.out/smoke.cjs",
"smoke": "npm run smoke:shared && npm run smoke:store && npm run smoke:app",
"smoke": "npm run smoke:shared && npm run smoke:store && npm run smoke:cookies && npm run smoke:app",
"smoke:cliproxy": "esbuild test/cliproxy.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cliproxy.cjs && electron test/.out/cliproxy.cjs",
"worktree:setup": "npm ci --prefer-offline --no-audit --no-fund && electron-builder install-app-deps",
"smoke:store": "node test/store-guard.mjs"
"smoke:store": "node test/store-guard.mjs",
"smoke:cookies": "esbuild test/cookies.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cookies.cjs && electron test/.out/cookies.cjs"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.85",
Expand Down
32 changes: 24 additions & 8 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SessionConfigPatch } from '../../shared/session-config'
import type { ClipboardAction } from '../../shared/context-menu'
import { clipboardHasContent, runClipboardAction } from '../services/context-menu'
import type {
CookieRow,
CreateChatInput,
CreateLoopInput,
CreateWorktreeInput,
Expand All @@ -28,6 +29,7 @@ import * as repo from '../db/repo'
import * as copilot from '../services/copilot'
import * as cliproxy from '../services/cliproxy'
import * as browser from '../services/browser'
import * as cookies from '../services/cookies'
import { listModels } from '../services/models'
import { pickDefaultModel } from '../../shared/models'
import { CLIPROXY_PROVIDER_IDS, accountsFor, isCliProxyProvider } from '../../shared/cliproxy'
Expand Down Expand Up @@ -87,7 +89,7 @@ const llmControllers = new Map<string, AbortController>()
*
* `llmControllers` alone was not enough for Stop to be reliable. The renderer
* only learns a requestId once the turn is actually starting, and real work
* happens before that most of all compaction, which is a full model call on a
* happens before that — most of all compaction, which is a full model call on a
* long history and used to run with a hardcoded never-aborted signal. Stop
* during that window found no requestId and silently did nothing, which is a
* large part of why the button felt stuck.
Expand Down Expand Up @@ -285,7 +287,7 @@ export function registerIpc(): void {
// Fire-and-forget: deletion must never block on git, so a failure here is
// logged and the session goes anyway (`git:prune-worktrees` sweeps up
// whatever is left behind). It re-kills the session's processes internally
// and awaits them the ordering that keeps removal working on Windows.
// and awaits them — the ordering that keeps removal working on Windows.
void removeWorktreeForChat(id).then(
(r) => {
if (!r.ok && r.error) console.warn('[worktree] remove on delete failed:', r.error)
Expand Down Expand Up @@ -409,7 +411,7 @@ export function registerIpc(): void {
state: getUpdateState()
}))
ipcMain.handle(CHANNELS.systemOpenExternal, async (_e, url: string) => {
// Only allow web URLs never file:, javascript:, or other schemes.
// Only allow web URLs — never file:, javascript:, or other schemes.
try {
const parsed = new URL(url)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
Expand Down Expand Up @@ -653,7 +655,7 @@ export function registerIpc(): void {
// If this session is shared to a phone, relay the turn there too so the phone
// streams a desktop-typed reply live (the mirror of a phone turn on the PC).
// The current prompt is the last user message; announce it so the phone shows
// the bubble it never echoed. `null` when nothing's shared zero overhead.
// the bubble it never echoed. `null` when nothing's shared → zero overhead.
const lastUser = [...input.messages].reverse().find((m) => m.role === 'user')
const relay = remote.relayLocalTurnStart(input.sessionId, lastUser?.content)
try {
Expand All @@ -677,7 +679,7 @@ export function registerIpc(): void {
llmControllers.get(requestId)?.abort()
})
// Stop, as the UI means it: end everything this session has in flight,
// whatever stage it's at. Also cancels the session's delegates stopping a
// whatever stage it's at. Also cancels the session's delegates — stopping a
// turn while it waits on a subagent has to stop the subagent, or the work
// carries on invisibly after the transcript says it stopped.
ipcMain.handle(CHANNELS.llmAbortSession, (_e, sessionId: string) => {
Expand Down Expand Up @@ -765,6 +767,20 @@ export function registerIpc(): void {
browser.moveTab(id, toIndex, keyOf(e))
)

// ---- cookies (the built-in Cookie-Editor) ----
// One jar, shared by every session: the browser's persisted partition is
// global, so these are deliberately NOT keyed off the sender. Both surfaces
// -- the browser window's Cookies panel and Settings -> Browser -- call the
// same handlers and see the same cookies.
ipcMain.handle(CHANNELS.cookiesList, (_e, url?: string) => cookies.list(url))
ipcMain.handle(CHANNELS.cookiesSet, (_e, row: Partial<CookieRow>) => cookies.set(row))
ipcMain.handle(CHANNELS.cookiesRemove, (_e, row: CookieRow) => cookies.remove(row))
ipcMain.handle(CHANNELS.cookiesClear, (_e, host?: string) => cookies.clear(host))
ipcMain.handle(CHANNELS.cookiesImport, (_e, text: string) => cookies.importJson(text))
ipcMain.handle(CHANNELS.browserChromeHeight, (e, height: number) =>
browser.setChromeHeight(height, keyOf(e))
)

// ---- services (a session's background processes) ----
// Every handler resolves the ROOT session first: a subagent's dev server is
// registered under its parent, and the parent's panel is where it belongs.
Expand Down Expand Up @@ -836,9 +852,9 @@ export function registerIpc(): void {
ipcMain.handle(
CHANNELS.gitCreateWorktree,
async (_e, input: CreateWorktreeInput): Promise<CreateWorktreeResult> => {
if (!(await git.isGitAvailable())) return { ok: false, error: 'Git isn’t installed.' }
if (!(await git.isGitAvailable())) return { ok: false, error: 'Git isn’t installed.' }
const root = await git.repoRoot(input.cwd)
if (!root) return { ok: false, error: 'This folder isn’t a git repository.' }
if (!root) return { ok: false, error: 'This folder isn’t a git repository.' }
const r =
input.mode === 'new'
? await git.createWorktree({
Expand All @@ -851,7 +867,7 @@ export function registerIpc(): void {
)

ipcMain.handle(CHANNELS.gitRemoveWorktree, async (_e, worktreePath: string, force?: boolean) => {
if (!(await git.isGitAvailable())) return { ok: false, error: 'Git isn’t installed.' }
if (!(await git.isGitAvailable())) return { ok: false, error: 'Git isn’t installed.' }
return git.removeWorktree(worktreePath, { force: force ?? false })
})

Expand Down
60 changes: 42 additions & 18 deletions src/main/services/browser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* The Roxy browser a real, persistent Electron BrowserWindow the agent can
* The Roxy browser — a real, persistent Electron BrowserWindow the agent can
* drive. It uses a `persist:` session partition so cookies/logins survive
* restarts (sign in once, automate forever), and it taps Electron's native
* APIs to screenshot the page, read its HTML, and collect console messages.
Expand All @@ -22,17 +22,17 @@ import { CHANNELS } from '../../shared/ipc'
import type { BrowserState, BrowserTab } from '../../shared/api'
import { attachNativeContextMenu } from './context-menu'

/** Persisted session cookies, localStorage and logins survive app restarts. */
const PARTITION = 'persist:roxy-browser'
/** Persisted session → cookies, localStorage and logins survive app restarts. */
export const PARTITION = 'persist:roxy-browser'
const MAX_CONSOLE = 500
/** Height of the chrome overlaid at the top: tab strip + URL-bar toolbar. */
const CHROME_H = 80
/** Where a blank/new tab lands a homepage, like a normal browser. */
/** Where a blank/new tab lands — a homepage, like a normal browser. */
const HOME_URL = 'https://www.google.com'
/** The shared window used by the manual "Open browser" button and keyless callers. */
const DEFAULT_KEY = '__default__'

/** App icon path, injected from main (so this file has no `?asset` import the
/** App icon path, injected from main (so this file has no `?asset` import — the
* smoke harness bundles it with esbuild, which doesn't understand `?asset`). */
let appIconPath: string | undefined
export function setAppIcon(p: string): void {
Expand All @@ -48,14 +48,14 @@ export interface ConsoleEntry {
ts: number
}

/** One open tab a persistent-session page view. The active tab fills the
/** One open tab — a persistent-session page view. The active tab fills the
* window; the rest sit at zero size (still alive, so their pages are kept). */
interface Tab {
id: string
view: BrowserView
}

/** One isolated browser a window + its tabs + console, owned by a session key. */
/** One isolated browser — a window + its tabs + console, owned by a session key. */
interface Session {
key: string
/** A human label (usually the project folder) shown in the window title. */
Expand All @@ -65,6 +65,14 @@ interface Session {
activeTabId: string | null
consoleLog: ConsoleEntry[]
tabSeq: number
/**
* Chrome height in px when a chrome panel (the cookie editor) is open.
* BrowserViews always paint ABOVE the window's own webContents, so a panel
* rendered in the chrome would be hidden behind the page. Growing the chrome
* instead pushes the page view down out of sight -- the panel is genuinely
* on top, with no z-index fight to lose.
*/
chromeH?: number
}

/** Every live browser, keyed by session (chat) id. */
Expand All @@ -80,7 +88,7 @@ function getSession(key: string): Session {
return s
}

/** The session for `key` WITHOUT creating one for read/nav ops that must not spawn a window. */
/** The session for `key` WITHOUT creating one — for read/nav ops that must not spawn a window. */
function peek(key: string): Session | undefined {
return sessions.get(key)
}
Expand All @@ -91,17 +99,17 @@ function activeView(s: Session): BrowserView | null {
return t && !t.view.webContents.isDestroyed() ? t.view : null
}

/** The active page's contents for `key` what every browser_* tool drives. */
/** The active page's contents for `key` — what every browser_* tool drives. */
function pageContents(key: string): Electron.WebContents {
const s = peek(key)
const v = s ? activeView(s) : null
if (!v) throw new Error('No browser is open. Use browser_open first.')
return v.webContents
}

/** The window title for a session names the project so windows are tellable apart. */
/** The window title for a session — names the project so windows are tellable apart. */
function windowTitle(s: Session): string {
return s.label ? `Roxy Browser ${s.label}` : 'Roxy Browser'
return s.label ? `Roxy Browser — ${s.label}` : 'Roxy Browser'
}

function ensureWindow(s: Session): BrowserWindow {
Expand All @@ -121,7 +129,7 @@ function ensureWindow(s: Session): BrowserWindow {
backgroundColor: '#0a0a0a',
autoHideMenuBar: true,
...(isMac || !appIconPath ? {} : { icon: appIconPath }),
// Hide the native OS title bar our React chrome (tab strip + URL bar) IS
// Hide the native OS title bar — our React chrome (tab strip + URL bar) IS
// the title bar. Keep native window controls, themed to match (no second
// light bar stacked on top of the chrome).
titleBarStyle: 'hidden',
Expand All @@ -137,11 +145,11 @@ function ensureWindow(s: Session): BrowserWindow {
s.win = win

// The chrome (tab strip + URL bar) is a real React app rendered into the
// WINDOW's OWN webContents not a BrowserView so its title-bar strip is
// WINDOW's OWN webContents — not a BrowserView — so its title-bar strip is
// draggable via `-webkit-app-region` (which doesn't work inside a BrowserView)
// and the native control overlay lands on it cleanly. Page tabs sit in
// BrowserViews on top, below the chrome. Best-effort load (the test harness
// has no bundled browser.html the pages still work).
// has no bundled browser.html — the pages still work).
win.webContents.once('did-finish-load', () => {
pushState(s)
pushTabs(s)
Expand Down Expand Up @@ -171,16 +179,32 @@ function ensureWindow(s: Session): BrowserWindow {
function layout(s: Session): void {
if (!s.win || s.win.isDestroyed()) return
const { width, height } = s.win.getContentBounds()
const top = s.chromeH ?? CHROME_H
for (const t of s.tabs) {
if (t.view.webContents.isDestroyed()) continue
t.view.setBounds(
t.id === s.activeTabId
? { x: 0, y: CHROME_H, width, height: Math.max(0, height - CHROME_H) }
? { x: 0, y: top, width, height: Math.max(0, height - top) }
: { x: 0, y: 0, width: 0, height: 0 }
)
}
}

/**
* Reserve height px of window for the chrome, so a chrome-rendered panel can
* cover the page. Pass 0/undefined to restore the normal toolbar height.
*
* Clamped to the window, because a panel taller than the window would push the
* page view to a negative height and Chromium would reject the bounds.
*/
export function setChromeHeight(height: number, key: string = DEFAULT_KEY): void {
const s = peek(key)
if (!s || !s.win || s.win.isDestroyed()) return
const max = s.win.getContentBounds().height
s.chromeH = height > CHROME_H ? Math.min(height, max) : undefined
layout(s)
}

/** Create a tab (optionally at a URL) and make it active. Assumes a window. */
function createTab(s: Session, rawUrl?: string): string {
if (!s.win || s.win.isDestroyed()) return ''
Expand Down Expand Up @@ -262,7 +286,7 @@ function tabsOf(s: Session): BrowserTab[] {
})
}

/** The open tabs for `key` also used by the browser_tabs tool. */
/** The open tabs for `key` — also used by the browser_tabs tool. */
export function listTabs(key: string = DEFAULT_KEY): BrowserTab[] {
const s = peek(key)
return s ? tabsOf(s) : []
Expand Down Expand Up @@ -307,7 +331,7 @@ export async function open(
const wc = pageContents(key)
const url = normalizeUrl(rawUrl)
// Show the window so you can watch the agent browse, but DON'T steal focus
// (showInactive) the agent driving the browser shouldn't yank you out of
// (showInactive) — the agent driving the browser shouldn't yank you out of
// whatever you're typing. The Settings "Open browser" button (openWindow)
// still focuses it for manual sign-in.
if (s.win?.isMinimized()) s.win.restore()
Expand Down Expand Up @@ -378,7 +402,7 @@ export function close(key: string = DEFAULT_KEY): void {
const s = sessions.get(key)
if (!s) return
if (s.win && !s.win.isDestroyed())
s.win.close() // fires 'closed' sessions.delete
s.win.close() // fires 'closed' → sessions.delete
else sessions.delete(key)
}

Expand Down
Loading