Skip to content

Commit a386e6c

Browse files
committed
feat(browser): add link open target and presentation mode settings
- Add browser linkOpenTarget (internal/system) and linkPresentationMode (panel/overlay) shared settings with schema defaults and migration - Route external link opens through BrowserPanelManager so http(s) URLs can land in the embedded panel or overlay based on user preference - Extend BrowserSettings UI with selects for link target and presentation mode - Propagate presentation mode via open-panel browser event and honor it in useBrowserSync - Harden supervisor shutdown: track OpenCode server children for forced cleanup and guard ThreadSessionManager against start-after-dispose with new startClose tests
1 parent 9628d72 commit a386e6c

11 files changed

Lines changed: 592 additions & 53 deletions

File tree

src/main/browser/BrowserPanelManager.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { randomUUID } from "node:crypto";
2-
import { BrowserWindow } from "electron";
2+
import { BrowserWindow, shell } from "electron";
33
import type {
44
BrowserEvent,
55
BrowserState,
66
BrowserStartPickerResult,
77
BrowserTabInfo,
88
} from "@/shared/ipc";
99
import type { LightcodePaths } from "@/shared/lightcodePaths";
10+
import type { BrowserLinkOpenTarget, BrowserLinkPresentationMode } from "@/shared/settings";
1011
import { dbGetState, dbSetState } from "../db";
12+
import { readSharedSettingsFile } from "../sharedSettingsFile";
1113
import { saveClipboardImageFile } from "../attachments/localFiles";
1214
import { IPC_EVENT_CHANNELS } from "@/shared/ipc";
1315
import { BrowserTab, resolveWebContentsById } from "./BrowserTab";
@@ -17,6 +19,8 @@ import { buildPickerScript } from "./picker/pickerScript";
1719
const PERSIST_KEY = "browser-panel-tabs-v1";
1820
const PERSIST_DEBOUNCE_MS = 750;
1921
const ATTACH_TIMEOUT_MS = 8000;
22+
const INTERNAL_BROWSER_PROTOCOLS = new Set(["http:", "https:"]);
23+
const SYSTEM_BROWSER_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
2024

2125
interface PersistedTabsState {
2226
tabs: Array<{ url: string; title: string }>;
@@ -161,6 +165,51 @@ export class BrowserPanelManager {
161165
this.emit({ type: "open-panel" });
162166
}
163167

168+
private readLinkSettings(): {
169+
linkOpenTarget: BrowserLinkOpenTarget;
170+
linkPresentationMode: BrowserLinkPresentationMode;
171+
} {
172+
try {
173+
const browser = readSharedSettingsFile(this.paths.settingsPath).browser;
174+
return {
175+
linkOpenTarget: browser.linkOpenTarget,
176+
linkPresentationMode: browser.linkPresentationMode,
177+
};
178+
} catch {
179+
return { linkOpenTarget: "internal", linkPresentationMode: "panel" };
180+
}
181+
}
182+
183+
private async openSystemBrowser(rawUrl: string): Promise<boolean> {
184+
let url: URL;
185+
try {
186+
url = new URL(rawUrl);
187+
} catch {
188+
return false;
189+
}
190+
if (!SYSTEM_BROWSER_PROTOCOLS.has(url.protocol)) return false;
191+
await shell.openExternal(url.toString());
192+
return true;
193+
}
194+
195+
async openLink(rawUrl: string): Promise<boolean> {
196+
let url: URL;
197+
try {
198+
url = new URL(rawUrl);
199+
} catch {
200+
return false;
201+
}
202+
203+
const settings = this.readLinkSettings();
204+
if (settings.linkOpenTarget === "system" || !INTERNAL_BROWSER_PROTOCOLS.has(url.protocol)) {
205+
return this.openSystemBrowser(url.toString());
206+
}
207+
208+
this.emit({ type: "open-panel", mode: settings.linkPresentationMode });
209+
void this.createTab({ url: url.toString(), activate: true }).catch(() => {});
210+
return true;
211+
}
212+
164213
private toInfo(t: BrowserTab): BrowserTabInfo {
165214
const s = t.snapshot();
166215
return {
@@ -206,10 +255,8 @@ export class BrowserPanelManager {
206255
onAttention: (id) => {
207256
this.emit({ type: "tab-attention", tabId: id });
208257
},
209-
onPopup: (sourceTabId, popupUrl) => {
210-
void this.createTab({ url: popupUrl, activate: false }).then((info) => {
211-
if (sourceTabId) this.emit({ type: "tab-attention", tabId: info.tabId });
212-
});
258+
onPopup: (_sourceTabId, popupUrl) => {
259+
void this.openLink(popupUrl).catch(() => {});
213260
},
214261
});
215262
this.tabs.push(tab);

src/main/ipc/localHandlers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ export function createLocalIpcHandlers(
9292
saveHandoffContext: (payload) =>
9393
saveHandoffContextFile(options.requireLightcodePaths(), payload),
9494
openExternal: async (url) => {
95-
await shell.openExternal(assertSafeExternalUrl(url));
95+
const safeUrl = assertSafeExternalUrl(url);
96+
const browserPanel = options.getBrowserPanelManager();
97+
if (browserPanel && (await browserPanel.openLink(safeUrl))) {
98+
return;
99+
}
100+
await shell.openExternal(safeUrl);
96101
},
97102
focusWindow: () => {
98103
const win = options.getMainWindow();

src/main/sharedSettingsFile.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ describe("sharedSettingsFile", () => {
8383
favoriteModels: [],
8484
recentModels: [],
8585
agentHookSupport: {},
86-
browser: { allowEval: false, allowDataAccess: false },
86+
browser: {
87+
allowEval: false,
88+
allowDataAccess: false,
89+
linkOpenTarget: "internal",
90+
linkPresentationMode: "panel",
91+
},
8792
});
8893

8994
expect(readSharedSettingsFile(settingsPath)).toEqual({
@@ -143,7 +148,12 @@ describe("sharedSettingsFile", () => {
143148
favoriteModels: [],
144149
recentModels: [],
145150
agentHookSupport: {},
146-
browser: { allowEval: false, allowDataAccess: false },
151+
browser: {
152+
allowEval: false,
153+
allowDataAccess: false,
154+
linkOpenTarget: "internal",
155+
linkPresentationMode: "panel",
156+
},
147157
});
148158
expect(readFileSync(settingsPath, "utf8")).toContain('"themeMode": "dark"');
149159
});
@@ -215,4 +225,22 @@ describe("sharedSettingsFile", () => {
215225
providerConfigs: {},
216226
});
217227
});
228+
229+
it("normalizes older browser settings without dropping existing flags", () => {
230+
const settingsPath = join(makeTempDir(), "settings.json");
231+
writeFileSync(
232+
settingsPath,
233+
JSON.stringify({
234+
browser: { allowEval: true, allowDataAccess: true },
235+
}),
236+
"utf8",
237+
);
238+
239+
expect(readSharedSettingsFile(settingsPath).browser).toEqual({
240+
allowEval: true,
241+
allowDataAccess: true,
242+
linkOpenTarget: "internal",
243+
linkPresentationMode: "panel",
244+
});
245+
});
218246
});

src/renderer/app.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ describe("App", () => {
326326
threads: [],
327327
pendingThreadLaunches: {},
328328
pendingLaunchSegments: {},
329+
lastViewedAtByThreadId: {},
329330
view: { kind: "home" },
330331
}));
331332
useGitStore.setState({

src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/hooks/useBrowserSync.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@ export function useBrowserSync(): void {
1919
} else if (event.type === "tab-attention") {
2020
setAttention(event.tabId);
2121
} else if (event.type === "open-panel") {
22-
usePanelStore.getState().openBrowserPanel();
22+
const panel = usePanelStore.getState();
23+
if (event.mode === "overlay") {
24+
panel.setBrowserOverlayOpen(true);
25+
} else {
26+
if (event.mode === "panel") {
27+
panel.setBrowserOverlayOpen(false);
28+
}
29+
panel.openBrowserPanel();
30+
}
2331
} else if (event.type === "picker-cancelled") {
2432
setPickerActive(false);
2533
}
Lines changed: 82 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,64 @@
1+
import { startTransition } from "react";
12
import { Switch } from "@heroui/react";
3+
import { Select } from "@/renderer/components/common";
24
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
5+
import type { BrowserLinkOpenTarget, BrowserLinkPresentationMode } from "@/shared/settings";
6+
7+
const linkOpenTargetOptions = [
8+
{ id: "internal", label: "App Browser" },
9+
{ id: "system", label: "System Browser" },
10+
] as const;
11+
12+
const linkPresentationModeOptions = [
13+
{ id: "panel", label: "Right panel" },
14+
{ id: "overlay", label: "Fullscreen overlay" },
15+
] as const;
316

417
export function BrowserSettings() {
518
const allowEval = useSharedSettings((s) => s.browser.allowEval);
619
const allowDataAccess = useSharedSettings((s) => s.browser.allowDataAccess);
20+
const linkOpenTarget = useSharedSettings((s) => s.browser.linkOpenTarget);
21+
const linkPresentationMode = useSharedSettings((s) => s.browser.linkPresentationMode);
722
const setBrowserSetting = useSharedSettings((s) => s.setBrowserSetting);
823

924
return (
1025
<div className="h-full min-h-0 overflow-y-auto px-6 pb-8 pt-4">
1126
<div className="mx-auto max-w-[720px]">
12-
<h1 className="mb-2 text-lg font-semibold text-foreground">Browser</h1>
13-
<p className="mb-6 text-xs text-muted">
14-
The in-app browser lives in the right panel. Threads can opt in to the browser MCP via the
15-
composer "+" menu; once enabled they can navigate, click, type, query the DOM, and take
16-
screenshots inside this panel. The browser keeps running in the background even when the
17-
panel is hidden.
18-
</p>
27+
<h1 className="mb-6 text-lg font-semibold text-foreground">Browser</h1>
1928

2029
<div className="space-y-4">
30+
<SettingRow
31+
title="Open links in"
32+
description="Choose whether links from Lightcode and browser popups stay in Lightcode or open externally."
33+
>
34+
<Select
35+
aria-label="Open links in"
36+
className="w-[180px] shrink-0"
37+
options={linkOpenTargetOptions}
38+
value={linkOpenTarget}
39+
onChange={(value) => {
40+
startTransition(() => {
41+
setBrowserSetting("linkOpenTarget", value as BrowserLinkOpenTarget);
42+
});
43+
}}
44+
/>
45+
</SettingRow>
46+
<SettingRow
47+
title="Show opened links in"
48+
description="When links open in a Lightcode browser tab, choose where the browser is revealed."
49+
>
50+
<Select
51+
aria-label="Show opened links in"
52+
className="w-[180px] shrink-0"
53+
options={linkPresentationModeOptions}
54+
value={linkPresentationMode}
55+
onChange={(value) => {
56+
startTransition(() => {
57+
setBrowserSetting("linkPresentationMode", value as BrowserLinkPresentationMode);
58+
});
59+
}}
60+
/>
61+
</SettingRow>
2162
<SettingRow
2263
title="Allow eval"
2364
description={
@@ -26,9 +67,20 @@ export function BrowserSettings() {
2667
page. Off by default — turn on only when you trust the loaded sites and the agent.
2768
</>
2869
}
29-
value={allowEval}
30-
onChange={(v) => setBrowserSetting("allowEval", v)}
31-
/>
70+
>
71+
<Switch
72+
isSelected={allowEval}
73+
onChange={(selected) => {
74+
startTransition(() => {
75+
setBrowserSetting("allowEval", selected);
76+
});
77+
}}
78+
>
79+
<Switch.Control>
80+
<Switch.Thumb />
81+
</Switch.Control>
82+
</Switch>
83+
</SettingRow>
3284
<SettingRow
3385
title="Allow agents to read/write cookies and storage"
3486
description={
@@ -38,9 +90,20 @@ export function BrowserSettings() {
3890
agent and the sites it visits.
3991
</>
4092
}
41-
value={allowDataAccess}
42-
onChange={(v) => setBrowserSetting("allowDataAccess", v)}
43-
/>
93+
>
94+
<Switch
95+
isSelected={allowDataAccess}
96+
onChange={(selected) => {
97+
startTransition(() => {
98+
setBrowserSetting("allowDataAccess", selected);
99+
});
100+
}}
101+
>
102+
<Switch.Control>
103+
<Switch.Thumb />
104+
</Switch.Control>
105+
</Switch>
106+
</SettingRow>
44107
</div>
45108
</div>
46109
</div>
@@ -50,25 +113,15 @@ export function BrowserSettings() {
50113
function SettingRow(props: {
51114
title: string;
52115
description: React.ReactNode;
53-
value: boolean;
54-
onChange: (v: boolean) => void;
55-
disabled?: boolean;
116+
children: React.ReactNode;
56117
}) {
57118
return (
58-
<div className="flex items-start justify-between gap-4 rounded-lg border border-border bg-[var(--surface-background,#0d1117)] px-4 py-3">
59-
<div className="min-w-0 flex-1">
60-
<div className="text-sm font-medium text-foreground">{props.title}</div>
61-
<div className="mt-1 text-xs text-muted">{props.description}</div>
119+
<div className="flex items-center justify-between gap-4">
120+
<div className="min-w-0">
121+
<p className="text-sm font-medium text-foreground">{props.title}</p>
122+
<p className="text-xs text-muted">{props.description}</p>
62123
</div>
63-
<Switch
64-
isSelected={props.value}
65-
isDisabled={props.disabled === true}
66-
onChange={(selected) => props.onChange(selected)}
67-
>
68-
<Switch.Control>
69-
<Switch.Thumb />
70-
</Switch.Control>
71-
</Switch>
124+
{props.children}
72125
</div>
73126
);
74127
}

src/shared/ipc/events.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
ThreadStatusSource,
1212
} from "../contracts";
1313
import type { BrowserState, BrowserTabInfo } from "./procedures/browser";
14+
import type { BrowserLinkPresentationMode } from "../settings";
1415
import type { IpcProcedurePayload, SupervisorProcedureName } from "./procedureMap";
1516

1617
export type SupervisorRequest = {
@@ -83,7 +84,7 @@ export type BrowserEvent =
8384
| { type: "state"; state: BrowserState }
8485
| { type: "tab-updated"; tab: BrowserTabInfo }
8586
| { type: "tab-attention"; tabId: string }
86-
| { type: "open-panel" }
87+
| { type: "open-panel"; mode?: BrowserLinkPresentationMode }
8788
| { type: "picker-cancelled" };
8889

8990
export type UpdateStatus =

0 commit comments

Comments
 (0)