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
5 changes: 5 additions & 0 deletions src/main/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ export function detectApps(): { editors: string[]; browsers: string[] } {
* Used as the default handoff target before falling back to a hardcoded app. */
export const firstInstalledEditor = (): string => EDITOR_APPS.find(appInstalled) || ''
export const firstInstalledBrowser = (): string => BROWSER_APPS.find(appInstalled) || ''

/** Every installed browser, in preference order. The handoff walks this as a
* chain so a configured browser that is missing on THIS machine falls through
* to the next real one instead of straight to the OS default. */
export const installedBrowsers = (): string[] => BROWSER_APPS.filter(appInstalled)
25 changes: 19 additions & 6 deletions src/main/ipc/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { repoRootOf } from '../repo'
import { resolveWithinAny } from '../path-guard'
import { isExternallyOpenableUrl } from '../../shared/url-safety'
import {
resolvedBrowserApp,
resolvedBrowserApps,
resolvedEditorApp,
resolvedProjectsDir,
resolvedWorktreesDir,
Expand Down Expand Up @@ -116,15 +116,28 @@ export function registerSystemIpc(deps: SystemIpcDeps): void {
})
// Hand a target to a configured external app via `open -a <App>` (robust, no
// PATH/CLI dependency), falling back to the OS default if the app isn't there.
function openInApp(appName: string, target: string, fallback: () => void) {
// Walks a candidate chain, best app first, and only reaches `fallback` once
// every candidate has failed. `error` and `exit` can BOTH fire for a single
// failed spawn (a failed spawn exits with a null code, which is != 0), so the
// settled latch is what stops one failure opening the target twice.
function openInApp(candidates: string | string[], target: string, fallback: () => void) {
const [appName, ...rest] = typeof candidates === 'string' ? [candidates] : candidates
if (!appName) return fallback()
let settled = false
const advance = () => {
if (settled) return
settled = true
openInApp(rest, target, fallback)
}
try {
const p = cpSpawn('open', ['-a', appName, target], { stdio: 'ignore' })
p.on('error', fallback)
p.on('error', advance)
p.on('exit', (code) => {
if (code !== 0) fallback()
if (code === 0) settled = true
else advance()
})
} catch {
fallback()
advance()
}
}
// "Open in browser" — the configured browser (default Brave) with its extensions/wallet.
Expand All @@ -133,7 +146,7 @@ export function registerSystemIpc(deps: SystemIpcDeps): void {
// simply a second, unguarded way to reach an OS protocol handler.
handle('open:in-browser', (_e, url: string) => {
if (!isExternallyOpenableUrl(url)) return openExternalSafe(url)
openInApp(resolvedBrowserApp(), url, () => openExternalSafe(url))
openInApp(resolvedBrowserApps(), url, () => openExternalSafe(url))
})
// "Open in editor" — the configured editor (default Cursor). Opens a path; defaults
// to the active session's repo root. Renderer-supplied paths are constrained to
Expand Down
8 changes: 6 additions & 2 deletions src/main/loops-experiment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,12 @@ describe('the gate is wired where it matters', () => {
test('the EntryScreen loop affordance is behind useExperiment', () => {
const src = read('src/renderer/src/components/EntryScreen.tsx')
expect(src).toContain("useExperiment('loops')")
// The mode toggle must not offer 'loop' when the flag is off.
expect(src).toContain("loopsOn ? (['single', 'loop'] as const) : (['single'] as const)")
// The action select must not offer 'loop' when the flag is off: the option
// list filters on loopsOn…
expect(src).toContain("a.id !== 'loop' || loopsOn")
// …and flipping the flag off mid-session must not strand the screen in a
// loop form whose launch button main would refuse.
expect(src).toMatch(/if \(!loopsOn\) setAction\(/)
})

test('the renderer consumes stop and state — every startable loop is stoppable', () => {
Expand Down
11 changes: 10 additions & 1 deletion src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join, dirname } from 'node:path'
import { homedir } from 'node:os'
import { quarantineCorruptFile, readJsonState, withFileLock, writeFileAtomic } from './atomic-write'
import { configPath } from './config-dir'
import { firstInstalledEditor, firstInstalledBrowser } from './apps'
import { firstInstalledEditor, firstInstalledBrowser, installedBrowsers } from './apps'
import { DEFAULT_BRIDGE_PORT } from './bridge/identity'
import {
NOTIFY_CATEGORIES,
Expand Down Expand Up @@ -935,3 +935,12 @@ export const resolvedEditorApp = () =>
readSettings().apps.editor || firstInstalledEditor() || DEFAULT_EDITOR
export const resolvedBrowserApp = () =>
readSettings().apps.browser || firstInstalledBrowser() || DEFAULT_BROWSER
/** The browser handoff's full candidate chain, best first: the configured app,
* then every other installed browser. The configured app leads even when we
* can't see it in /Applications — it may live somewhere we don't scan — but a
* machine that simply doesn't have it now falls through to a browser it DOES
* have instead of bailing to the OS default. */
export const resolvedBrowserApps = (): string[] => {
const chain = [...new Set([readSettings().apps.browser, ...installedBrowsers()].filter(Boolean))]
return chain.length ? chain : [DEFAULT_BROWSER]
}
2 changes: 1 addition & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1348,7 +1348,7 @@ export default function App() {
{/* session tab bar (top-level, also the window drag region) */}
<header
style={drag}
className={`flex h-9 shrink-0 items-center border-b border-[var(--gt-border)] bg-[var(--gt-bg)] pr-2 ${fullscreen ? 'pl-3' : 'pl-[78px]'}`}
className={`flex h-9 shrink-0 items-center border-b border-[var(--gt-border)] bg-[var(--gt-bg)]/70 pr-2 backdrop-blur-md ${fullscreen ? 'pl-3' : 'pl-[78px]'}`}
>
{/* brand mark — the logo asset is already tightly cropped, so it fills
the box at scale-1 (no extra zoom) */}
Expand Down
Loading
Loading