getobsrv 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12688,12 +12688,19 @@ const createImpl = (createState) => {
12688
12688
  };
12689
12689
  const create = ((createState) => createImpl);
12690
12690
  const MAX_VIEWPORT = 4096;
12691
+ const SPLIT_MIN = 0.1;
12692
+ const SPLIT_MAX = 0.9;
12693
+ const MAX_TABS_MIN = 2;
12694
+ const MAX_TABS_MAX = 32;
12691
12695
  const DEFAULT_SETTINGS = {
12692
12696
  hostDiagonalInches: 27,
12693
12697
  hostNits: 500,
12694
12698
  agentControl: false,
12695
12699
  updateCheck: true,
12696
- lastUpdateCheck: 0
12700
+ lastUpdateCheck: 0,
12701
+ recordHistory: true,
12702
+ split: 0.5,
12703
+ maxTabs: 12
12697
12704
  };
12698
12705
  const SCREEN_PRESETS = [
12699
12706
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -12757,6 +12764,27 @@ function profileToParams(p, hostNits) {
12757
12764
  dither: p.frc
12758
12765
  };
12759
12766
  }
12767
+ function closeTab(tabs, closeId, activeId) {
12768
+ const index = tabs.findIndex((t) => t.id === closeId);
12769
+ if (index === -1) return { tabs, activeId };
12770
+ const next = tabs.filter((t) => t.id !== closeId);
12771
+ if (next.length === 0) return { tabs: next, activeId: null };
12772
+ if (closeId !== activeId) return { tabs: next, activeId };
12773
+ const neighbour = next[Math.min(index, next.length - 1)];
12774
+ return { tabs: next, activeId: neighbour.id };
12775
+ }
12776
+ function canAddTab(count, max) {
12777
+ return count < max;
12778
+ }
12779
+ function tabTitle(url, pageTitle) {
12780
+ if (pageTitle.trim() !== "") return pageTitle;
12781
+ if (url.trim() === "" || url.trim() === "about:blank") return "New tab";
12782
+ try {
12783
+ return new URL(url).host || url;
12784
+ } catch {
12785
+ return url;
12786
+ }
12787
+ }
12760
12788
  const CUSTOM_PRESET_ID = "custom";
12761
12789
  const FALLBACK_SCALE = 2;
12762
12790
  function sameError(a, b) {
@@ -12764,81 +12792,175 @@ function sameError(a, b) {
12764
12792
  if (a === null || b === null) return false;
12765
12793
  return a.code === b.code && a.url === b.url && a.description === b.description;
12766
12794
  }
12767
- const useStore = create()((set) => ({
12768
- mode: "url",
12769
- url: "",
12770
- lastUrl: "",
12771
- presetId: "1080p-24",
12772
- custom: { width: 1920, height: 1080, diagonalInches: 24 },
12773
- pixelExact: false,
12774
- profileId: PANEL_PROFILES[0].id,
12775
- profileOverride: null,
12795
+ function blankTab() {
12796
+ return {
12797
+ mode: "url",
12798
+ url: "",
12799
+ title: "",
12800
+ lastUrl: "",
12801
+ presetId: "1080p-24",
12802
+ custom: { width: 1920, height: 1080, diagonalInches: 24 },
12803
+ pixelExact: false,
12804
+ profileId: PANEL_PROFILES[0].id,
12805
+ profileOverride: null,
12806
+ targetLoading: false,
12807
+ error: null,
12808
+ image: null,
12809
+ // Fit, not 1:1: fit never enlarges past 1:1, so a render that already fits
12810
+ // its pane opens at true magnification anyway, while one that does not is
12811
+ // shown whole instead of as its top-left corner. Fit is interactive, so
12812
+ // this costs nothing — and the footer names the actual magnification
12813
+ // whenever fit is minifying.
12814
+ viewMode: "fit",
12815
+ fitScale: null,
12816
+ agentPan: null,
12817
+ agentHighlight: null
12818
+ };
12819
+ }
12820
+ let nextTabId = 0;
12821
+ function newTabId() {
12822
+ nextTabId += 1;
12823
+ return `local-${nextTabId}`;
12824
+ }
12825
+ const patchActiveWith = (f) => (s) => {
12826
+ const active = s.tabs[s.activeId];
12827
+ const patch = f(active);
12828
+ return patch === null ? {} : { tabs: { ...s.tabs, [s.activeId]: { ...active, ...patch } } };
12829
+ };
12830
+ const patchActive = (patch) => patchActiveWith(() => patch);
12831
+ const patchTabWith = (id, f) => (s) => {
12832
+ const t = s.tabs[id];
12833
+ if (!t) return {};
12834
+ const patch = f(t);
12835
+ return patch === null ? {} : { tabs: { ...s.tabs, [id]: { ...t, ...patch } } };
12836
+ };
12837
+ const FIRST_TAB = newTabId();
12838
+ const useStore = create()((set, get) => ({
12839
+ tabs: { [FIRST_TAB]: blankTab() },
12840
+ tabOrder: [FIRST_TAB],
12841
+ activeId: FIRST_TAB,
12776
12842
  settings: { ...DEFAULT_SETTINGS },
12777
12843
  // Zeroes until the first `getHostInfo`; `selectScale` falls back meanwhile.
12778
12844
  host: { physicalWidth: 0, physicalHeight: 0, scaleFactor: 0 },
12779
- targetLoading: false,
12780
- error: null,
12781
12845
  toast: null,
12782
12846
  update: null,
12783
- image: null,
12847
+ history: [],
12784
12848
  surround: "graphite",
12785
- // Fit, not 1:1: fit never enlarges past 1:1, so a render that already fits
12786
- // its pane opens at true magnification anyway, while one that does not is
12787
- // shown whole instead of as its top-left corner. Fit is interactive, so
12788
- // this costs nothing — and the footer names the actual magnification
12789
- // whenever fit is minifying.
12790
- viewMode: "fit",
12791
12849
  panes: "both",
12792
- fitScale: null,
12793
- agentPan: null,
12794
- agentHighlight: null,
12795
12850
  // Does not clear `error`: a failed load navigates to Chromium's error page,
12796
12851
  // so clearing here would wipe the toolbar badge the moment it appeared.
12797
12852
  // Does clear the agent highlight: it marked pixels of the page that was
12798
12853
  // showing, and a committed navigation (a reload included) replaces them.
12799
- setUrl: (url) => set({ url, agentHighlight: null }),
12854
+ setUrl: (url) => set((s) => patchTabWith(s.activeId, () => ({ url, agentHighlight: null }))(s)),
12855
+ setTabUrl: (id, url) => set(patchTabWith(id, () => ({ url, agentHighlight: null }))),
12856
+ setTabTitle: (id, title) => set(patchTabWith(id, (t) => t.title === title ? null : { title })),
12857
+ // Both panes report the same failure; see `setError`.
12858
+ setTabError: (id, error) => set(patchTabWith(id, (t) => sameError(t.error, error) ? null : { error })),
12859
+ setTabLoading: (id, targetLoading) => set(patchTabWith(id, () => ({ targetLoading }))),
12800
12860
  // A screen change re-rasters the target, so a highlight's target-pixel rect
12801
12861
  // no longer marks what it marked; the same for the custom fields below.
12802
- setPreset: (presetId) => set({ presetId, agentHighlight: null }),
12803
- setCustom: (c) => set((s) => ({ custom: { ...s.custom, ...c }, presetId: CUSTOM_PRESET_ID, agentHighlight: null })),
12804
- setPixelExact: (pixelExact) => set({ pixelExact }),
12862
+ setPreset: (presetId) => set(patchActive({ presetId, agentHighlight: null })),
12863
+ setCustom: (c) => set(patchActiveWith((t) => ({ custom: { ...t.custom, ...c }, presetId: CUSTOM_PRESET_ID, agentHighlight: null }))),
12864
+ setPixelExact: (pixelExact) => set(patchActive({ pixelExact })),
12805
12865
  // Picking a profile drops any hand-tuned slider values.
12806
- setProfile: (profileId) => set({ profileId, profileOverride: null }),
12807
- setProfileOverride: (profileOverride) => set({ profileOverride }),
12866
+ setProfile: (profileId) => set(patchActive({ profileId, profileOverride: null })),
12867
+ setProfileOverride: (profileOverride) => set(patchActive({ profileOverride })),
12808
12868
  setSettings: (settings) => set({ settings }),
12809
12869
  setHost: (host) => set({ host }),
12810
- setTargetLoading: (targetLoading) => set({ targetLoading }),
12870
+ setTargetLoading: (targetLoading) => set(patchActive({ targetLoading })),
12811
12871
  // Both panes report the same `loadError` for one failed navigation; the
12812
12872
  // duplicate must not replace the object and re-render everything twice.
12813
- setError: (error) => set((s) => sameError(s.error, error) ? {} : { error }),
12873
+ setError: (error) => set(patchActiveWith((t) => sameError(t.error, error) ? null : { error })),
12814
12874
  setUpdate: (update) => set({ update }),
12875
+ setHistory: (history) => set({ history }),
12815
12876
  setToast: (toast) => set({ toast }),
12816
- setImage: (image) => set({ image }),
12877
+ setImage: (image) => set(patchActive({ image })),
12817
12878
  setSurround: (surround) => set({ surround }),
12818
- setViewMode: (viewMode) => set({ viewMode }),
12879
+ setViewMode: (viewMode) => set(patchActive({ viewMode })),
12819
12880
  // No `agentHighlight: null` here, unlike setPreset: hiding a pane does not
12820
12881
  // re-raster the target, so the highlight still marks the pixels it marked.
12821
12882
  setPanes: (panes) => set({ panes }),
12822
- setFitScale: (fitScale2) => set({ fitScale: fitScale2 }),
12823
- requestAgentPan: (p) => set((s) => ({ agentPan: { ...p, seq: (s.agentPan?.seq ?? 0) + 1 } })),
12824
- clearAgentPan: () => set({ agentPan: null }),
12825
- showAgentHighlight: (h) => set((s) => ({ agentHighlight: { ...h, seq: (s.agentHighlight?.seq ?? 0) + 1 } })),
12826
- clearAgentHighlight: (seq) => set((s) => seq === void 0 || s.agentHighlight?.seq === seq ? { agentHighlight: null } : {}),
12883
+ setFitScale: (fitScale2) => set(patchActive({ fitScale: fitScale2 })),
12884
+ requestAgentPan: (p) => set(patchActiveWith((t) => ({ agentPan: { ...p, seq: (t.agentPan?.seq ?? 0) + 1 } }))),
12885
+ clearAgentPan: () => set(patchActive({ agentPan: null })),
12886
+ showAgentHighlight: (h) => set(patchActiveWith((t) => ({ agentHighlight: { ...h, seq: (t.agentHighlight?.seq ?? 0) + 1 } }))),
12887
+ clearAgentHighlight: (seq) => set(patchActiveWith((t) => seq === void 0 || t.agentHighlight?.seq === seq ? { agentHighlight: null } : null)),
12827
12888
  // Spec §7: leaving image mode restores the URL that was showing before.
12828
12889
  // Either direction swaps what the target pane shows, so a highlight over
12829
12890
  // the old content is dropped with it.
12830
12891
  setMode: (mode) => set(
12831
- (s) => mode === s.mode ? {} : mode === "image" ? { mode, lastUrl: s.url, agentHighlight: null } : { mode, url: s.lastUrl, image: null, agentHighlight: null }
12832
- )
12892
+ patchActiveWith(
12893
+ (t) => mode === t.mode ? null : mode === "image" ? { mode, lastUrl: t.url, agentHighlight: null } : { mode, url: t.lastUrl, image: null, agentHighlight: null }
12894
+ )
12895
+ ),
12896
+ syncTabs: (snap) => set((s) => {
12897
+ if (snap.tabs.length === 0) return {};
12898
+ const tabs = {};
12899
+ for (const info of snap.tabs) {
12900
+ const existing = s.tabs[info.id];
12901
+ tabs[info.id] = existing ? (
12902
+ // url and title are main's to know — it is what every tab's panes
12903
+ // report to — so the snapshot is authoritative for those two and
12904
+ // for nothing else.
12905
+ existing.url === info.url && existing.title === info.title ? existing : { ...existing, url: info.url, title: info.title }
12906
+ ) : (
12907
+ // A tab the renderer has never seen. Its screen comes from the
12908
+ // snapshot rather than from `blankTab`'s defaults, because main
12909
+ // may have restored it from disk with a preset chosen in a
12910
+ // previous launch — and for a tab genuinely opened just now, the
12911
+ // session's own defaults are those same defaults.
12912
+ {
12913
+ ...blankTab(),
12914
+ url: info.url,
12915
+ title: info.title,
12916
+ presetId: info.presetId,
12917
+ profileId: info.profileId
12918
+ }
12919
+ );
12920
+ }
12921
+ const tabOrder = snap.tabs.map((t) => t.id);
12922
+ return { tabs, tabOrder, activeId: tabs[snap.activeId] ? snap.activeId : tabOrder[0] };
12923
+ }),
12924
+ addTab: (id) => {
12925
+ const s = get();
12926
+ if (!canAddTab(s.tabOrder.length, s.settings.maxTabs)) return null;
12927
+ const next = id ?? newTabId();
12928
+ if (s.tabs[next]) {
12929
+ set({ activeId: next });
12930
+ return next;
12931
+ }
12932
+ set({ tabs: { ...s.tabs, [next]: blankTab() }, tabOrder: [...s.tabOrder, next], activeId: next });
12933
+ return next;
12934
+ },
12935
+ closeTab: (id) => set((s) => {
12936
+ if (!s.tabs[id]) return {};
12937
+ const result = closeTab(
12938
+ s.tabOrder.map((tabId) => ({ id: tabId })),
12939
+ id,
12940
+ s.activeId
12941
+ );
12942
+ if (result.activeId === null) {
12943
+ const fresh = newTabId();
12944
+ return { tabs: { [fresh]: blankTab() }, tabOrder: [fresh], activeId: fresh };
12945
+ }
12946
+ const tabs = { ...s.tabs };
12947
+ delete tabs[id];
12948
+ return { tabs, tabOrder: result.tabs.map((t) => t.id), activeId: result.activeId };
12949
+ }),
12950
+ activateTab: (id) => set((s) => s.tabs[id] ? { activeId: id } : {})
12833
12951
  }));
12952
+ function selectTab(s) {
12953
+ return s.tabs[s.activeId];
12954
+ }
12834
12955
  function selectScreen(s) {
12835
- const preset = SCREEN_PRESETS.find((p) => p.id === s.presetId);
12956
+ const tab = selectTab(s);
12957
+ const preset = SCREEN_PRESETS.find((p) => p.id === tab.presetId);
12836
12958
  return preset ? {
12837
12959
  width: preset.width,
12838
12960
  height: preset.height,
12839
12961
  diagonalInches: preset.diagonalInches,
12840
12962
  deviceScaleFactor: preset.deviceScaleFactor
12841
- } : s.custom;
12963
+ } : tab.custom;
12842
12964
  }
12843
12965
  function selectDeviceScaleFactor(s) {
12844
12966
  return selectScreen(s).deviceScaleFactor ?? 1;
@@ -12858,7 +12980,7 @@ function calibratedScale(s) {
12858
12980
  diagonalInches: s.settings.hostDiagonalInches,
12859
12981
  scaleFactor: s.host.scaleFactor
12860
12982
  };
12861
- const scale = computeScale(host, screen, s.pixelExact);
12983
+ const scale = computeScale(host, screen, selectTab(s).pixelExact);
12862
12984
  return Number.isFinite(scale) && scale > 0 ? scale : null;
12863
12985
  }
12864
12986
  function selectScale(s) {
@@ -12871,13 +12993,15 @@ function selectHostNits(s) {
12871
12993
  return s.settings.hostNits > 0 ? s.settings.hostNits : DEFAULT_SETTINGS.hostNits;
12872
12994
  }
12873
12995
  function selectProfile(s) {
12874
- return s.profileOverride ?? findProfile(s.profileId);
12996
+ const tab = selectTab(s);
12997
+ return tab.profileOverride ?? findProfile(tab.profileId);
12875
12998
  }
12876
12999
  function selectPanelParams(s) {
12877
13000
  return profileToParams(selectProfile(s), selectHostNits(s));
12878
13001
  }
12879
13002
  function selectUrlBarText(s) {
12880
- return s.mode === "image" ? s.image?.name ?? "" : s.url;
13003
+ const tab = selectTab(s);
13004
+ return tab.mode === "image" ? tab.image?.name ?? "" : tab.url;
12881
13005
  }
12882
13006
  const SCALES = [1, 2, 3];
12883
13007
  const DEFAULT_SCALE = 2;
@@ -13054,10 +13178,10 @@ function TargetFooter() {
13054
13178
  const params = useStore(useShallow(selectPanelParams));
13055
13179
  const scale = useStore(selectScale);
13056
13180
  const profile = useStore(selectProfile);
13057
- const image = useStore((s) => s.image);
13058
- const mode = useStore((s) => s.mode);
13059
- const viewMode = useStore((s) => s.viewMode);
13060
- const fitScale2 = useStore((s) => s.fitScale);
13181
+ const image = useStore((s) => selectTab(s).image);
13182
+ const mode = useStore((s) => selectTab(s).mode);
13183
+ const viewMode = useStore((s) => selectTab(s).viewMode);
13184
+ const fitScale2 = useStore((s) => selectTab(s).fitScale);
13061
13185
  const dsf = useStore(selectDeviceScaleFactor);
13062
13186
  const size = mode === "image" && image ? `${image.width}×${image.height}` : `${viewport.width}×${viewport.height}${dsf > 1 ? ` @${dsf}x` : ""}`;
13063
13187
  const depth = params.levels <= 63 ? "6-bit" : "8-bit";
@@ -13115,6 +13239,98 @@ function NativeSlot() {
13115
13239
  /* @__PURE__ */ jsxRuntimeExports.jsx(NativeFooter, { width: size.width, height: size.height })
13116
13240
  ] });
13117
13241
  }
13242
+ const MIN_PANE_PX = 240;
13243
+ const KEY_STEP = 0.02;
13244
+ const KEY_STEP_COARSE = 0.1;
13245
+ function PaneDivider() {
13246
+ const split = useStore((s) => s.settings.split);
13247
+ const ref = reactExports.useRef(null);
13248
+ const drag = reactExports.useRef(null);
13249
+ const geometry = () => {
13250
+ const el = ref.current;
13251
+ const row = el?.parentElement;
13252
+ if (!el || !row) return { left: 0, usable: 1, min: 0.5, max: 0.5 };
13253
+ const r = row.getBoundingClientRect();
13254
+ const usable2 = Math.max(1, r.width - el.getBoundingClientRect().width);
13255
+ const floor = MIN_PANE_PX / usable2;
13256
+ return {
13257
+ left: r.left,
13258
+ usable: usable2,
13259
+ min: Math.min(Math.max(SPLIT_MIN, floor), 0.5),
13260
+ max: Math.max(Math.min(SPLIT_MAX, 1 - floor), 0.5)
13261
+ };
13262
+ };
13263
+ const clamp = (ratio) => {
13264
+ const g = geometry();
13265
+ return Math.min(Math.max(ratio, g.min), g.max);
13266
+ };
13267
+ const preview = (ratio) => {
13268
+ const s = useStore.getState();
13269
+ const next = clamp(ratio);
13270
+ if (next === s.settings.split) return;
13271
+ s.setSettings({ ...s.settings, split: next });
13272
+ };
13273
+ const persist = (before) => {
13274
+ const now = useStore.getState().settings;
13275
+ if (now.split === before.split) return;
13276
+ window.obsrv.setSettings(now).catch(() => useStore.getState().setSettings(before));
13277
+ };
13278
+ const commit = (ratio) => {
13279
+ const before = useStore.getState().settings;
13280
+ preview(ratio);
13281
+ persist(before);
13282
+ };
13283
+ const onPointerDown = (e) => {
13284
+ if (e.button !== 0) return;
13285
+ e.preventDefault();
13286
+ const el = e.currentTarget;
13287
+ el.setPointerCapture(e.pointerId);
13288
+ drag.current = {
13289
+ before: useStore.getState().settings,
13290
+ grab: e.clientX - el.getBoundingClientRect().left
13291
+ };
13292
+ };
13293
+ const onPointerMove = (e) => {
13294
+ const d = drag.current;
13295
+ if (!d) return;
13296
+ const g = geometry();
13297
+ preview((e.clientX - d.grab - g.left) / g.usable);
13298
+ };
13299
+ const onPointerUp = (e) => {
13300
+ const d = drag.current;
13301
+ if (!d) return;
13302
+ drag.current = null;
13303
+ e.currentTarget.releasePointerCapture(e.pointerId);
13304
+ persist(d.before);
13305
+ };
13306
+ const onKeyDown = (e) => {
13307
+ const step = e.shiftKey ? KEY_STEP_COARSE : KEY_STEP;
13308
+ const delta = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
13309
+ if (!delta) return;
13310
+ e.preventDefault();
13311
+ commit(useStore.getState().settings.split + delta);
13312
+ };
13313
+ return /* @__PURE__ */ jsxRuntimeExports.jsx(
13314
+ "div",
13315
+ {
13316
+ ref,
13317
+ className: "pane-divider",
13318
+ role: "separator",
13319
+ "aria-orientation": "vertical",
13320
+ "aria-label": "Pane split",
13321
+ tabIndex: 0,
13322
+ "aria-valuenow": Math.round(split * 100),
13323
+ "aria-valuemin": Math.round(SPLIT_MIN * 100),
13324
+ "aria-valuemax": Math.round(SPLIT_MAX * 100),
13325
+ onPointerDown,
13326
+ onPointerMove,
13327
+ onPointerUp,
13328
+ onPointerCancel: onPointerUp,
13329
+ onDoubleClick: () => commit(0.5),
13330
+ onKeyDown
13331
+ }
13332
+ );
13333
+ }
13118
13334
  const CONTRAST_MAX = 3e3;
13119
13335
  const CUSTOM_PROFILE_ID = "custom";
13120
13336
  function profileToControls(p, hostNits) {
@@ -13308,7 +13524,8 @@ function SettingsPanel() {
13308
13524
  const host = useStore(useShallow((s) => s.host));
13309
13525
  const settings = useStore(useShallow((s) => s.settings));
13310
13526
  const update = useStore((s) => s.update);
13311
- const custom = useStore(useShallow((s) => s.custom));
13527
+ const history = useStore((s) => s.history);
13528
+ const custom = useStore(useShallow((s) => selectTab(s).custom));
13312
13529
  const viewport = useStore(useShallow(selectViewport));
13313
13530
  const scale = useStore(selectScale);
13314
13531
  const fallback = useStore(selectScaleIsFallback);
@@ -13464,7 +13681,52 @@ function SettingsPanel() {
13464
13681
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Check for updates automatically" })
13465
13682
  ] }),
13466
13683
  /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "check-now", onClick: () => void window.obsrv.checkUpdate(), children: "Check now" }),
13467
- /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." })
13684
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." }),
13685
+ /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "Tabs" }),
13686
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13687
+ NumberField,
13688
+ {
13689
+ className: "max-tabs",
13690
+ label: "Maximum tabs",
13691
+ unit: "tabs",
13692
+ value: settings.maxTabs,
13693
+ min: MAX_TABS_MIN,
13694
+ step: 1,
13695
+ onCommit: (v) => commit({
13696
+ ...useStore.getState().settings,
13697
+ maxTabs: Math.min(MAX_TABS_MAX, Math.max(MAX_TABS_MIN, Math.round(v)))
13698
+ }),
13699
+ onInvalid: setHostError
13700
+ }
13701
+ ),
13702
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Each tab is two Chromium renderers — a live pane and an offscreen render — plus the GPU and utility processes they pull in: twelve empty tabs measured 27 child processes and about 2.5 GB. A real page costs more. The cap is a memory decision, not a preference. Lowering it never closes a tab that is already open; it only stops new ones." }),
13703
+ /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "History" }),
13704
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "control inline record-history-toggle", children: [
13705
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13706
+ "input",
13707
+ {
13708
+ type: "checkbox",
13709
+ checked: settings.recordHistory,
13710
+ onChange: (e) => commit({ ...settings, recordHistory: e.target.checked })
13711
+ }
13712
+ ),
13713
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Remember visited addresses" })
13714
+ ] }),
13715
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13716
+ "button",
13717
+ {
13718
+ type: "button",
13719
+ className: "clear-history",
13720
+ disabled: history.length === 0,
13721
+ onClick: () => void window.obsrv.clearHistory(),
13722
+ children: "Clear history"
13723
+ }
13724
+ ),
13725
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "readout", children: [
13726
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "history-count num", children: history.length }),
13727
+ history.length === 1 ? " address remembered" : " addresses remembered"
13728
+ ] }),
13729
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Typed into the URL bar as suggestions and nowhere else. Turning this off stops recording and keeps what is stored; Clear erases it." })
13468
13730
  ] });
13469
13731
  }
13470
13732
  const VERT_SRC = `#version 300 es
@@ -13910,10 +14172,11 @@ function TargetCanvas({ onFatal, imageFrame }) {
13910
14172
  const params = useStore(useShallow(selectPanelParams));
13911
14173
  const dsf = useStore(selectDeviceScaleFactor);
13912
14174
  const requestedScale = useStore(selectScale);
13913
- const mode = useStore((s) => s.mode);
13914
- const viewMode = useStore((s) => s.viewMode);
14175
+ const mode = useStore((s) => selectTab(s).mode);
14176
+ const viewMode = useStore((s) => selectTab(s).viewMode);
13915
14177
  const setViewMode = useStore((s) => s.setViewMode);
13916
14178
  const setFitScale = useStore((s) => s.setFitScale);
14179
+ const activeId = useStore((s) => s.activeId);
13917
14180
  const [stalled, setStalled] = reactExports.useState(false);
13918
14181
  const stallTimer = reactExports.useRef(0);
13919
14182
  const armedOnce = reactExports.useRef(false);
@@ -13945,7 +14208,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
13945
14208
  const smooth = fit || scale < 1;
13946
14209
  reactExports.useEffect(() => {
13947
14210
  setFitScale(fit ? scale : null);
13948
- }, [fit, scale, setFitScale]);
14211
+ }, [fit, scale, activeId, setFitScale]);
13949
14212
  reactExports.useEffect(() => () => setFitScale(null), [setFitScale]);
13950
14213
  const draw = reactExports.useRef({ scale, params, smooth, dsf });
13951
14214
  const imageRef = reactExports.useRef(imageFrame);
@@ -13984,7 +14247,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
13984
14247
  offFrame = window.obsrv.onFrame((m) => {
13985
14248
  disarm();
13986
14249
  if (!gl) return;
13987
- if (useStore.getState().mode !== "url") return;
14250
+ if (selectTab(useStore.getState()).mode !== "url") return;
13988
14251
  gl.resizeSource(m.frameWidth, m.frameHeight);
13989
14252
  gl.uploadSlice(m.frame);
13990
14253
  schedule();
@@ -14012,7 +14275,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14012
14275
  const onWheel = (e) => {
14013
14276
  if (e.altKey) {
14014
14277
  e.preventDefault();
14015
- if (useStore.getState().viewMode !== "1:1") return;
14278
+ if (selectTab(useStore.getState()).viewMode !== "1:1") return;
14016
14279
  const body = canvas.closest(".pane-body");
14017
14280
  if (body instanceof HTMLElement) {
14018
14281
  body.scrollLeft += e.deltaX;
@@ -14020,7 +14283,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14020
14283
  }
14021
14284
  return;
14022
14285
  }
14023
- if (useStore.getState().mode !== "url") return;
14286
+ if (selectTab(useStore.getState()).mode !== "url") return;
14024
14287
  e.preventDefault();
14025
14288
  const r = canvas.getBoundingClientRect();
14026
14289
  const cssPerTarget = draw.current.scale * draw.current.dsf / (window.devicePixelRatio || 1);
@@ -14029,7 +14292,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14029
14292
  };
14030
14293
  canvas.addEventListener("wheel", onWheel, { passive: false });
14031
14294
  const onWindowUp = (e) => {
14032
- if (useStore.getState().mode !== "url") return;
14295
+ if (selectTab(useStore.getState()).mode !== "url") return;
14033
14296
  if (panRef.current || e.button === 1) return;
14034
14297
  if (e.altKey && !forwardDrag.current) return;
14035
14298
  if (e.target === canvas) return;
@@ -14052,8 +14315,10 @@ function TargetCanvas({ onFatal, imageFrame }) {
14052
14315
  };
14053
14316
  }, [onFatal, disarm]);
14054
14317
  reactExports.useEffect(
14055
- () => window.obsrv.onTargetNavigating(() => {
14056
- if (useStore.getState().mode === "url") arm();
14318
+ () => window.obsrv.onTargetNavigating(({ tabId }) => {
14319
+ const s = useStore.getState();
14320
+ if (tabId !== s.activeId) return;
14321
+ if (selectTab(s).mode === "url") arm();
14057
14322
  }),
14058
14323
  [arm]
14059
14324
  );
@@ -14167,7 +14432,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14167
14432
  );
14168
14433
  setViewMode("1:1");
14169
14434
  };
14170
- const agentPan = useStore((s) => s.agentPan);
14435
+ const agentPan = useStore((s) => selectTab(s).agentPan);
14171
14436
  const clearAgentPan = useStore((s) => s.clearAgentPan);
14172
14437
  reactExports.useEffect(() => {
14173
14438
  if (!agentPan) return;
@@ -14184,7 +14449,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14184
14449
  body.scrollTop = jump.top;
14185
14450
  }
14186
14451
  }, [agentPan]);
14187
- const agentHighlight = useStore((s) => s.agentHighlight);
14452
+ const agentHighlight = useStore((s) => selectTab(s).agentHighlight);
14188
14453
  const clearAgentHighlight = useStore((s) => s.clearAgentHighlight);
14189
14454
  reactExports.useEffect(() => {
14190
14455
  if (!agentHighlight) return;
@@ -14279,6 +14544,33 @@ function Toast() {
14279
14544
  if (!toast) return null;
14280
14545
  return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "toast", role: "status", children: toast });
14281
14546
  }
14547
+ const HISTORY_SUGGESTIONS = 6;
14548
+ function byRank(a, b) {
14549
+ if (a.lastVisit !== b.lastVisit) return b.lastVisit - a.lastVisit;
14550
+ if (a.visits !== b.visits) return b.visits - a.visits;
14551
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
14552
+ }
14553
+ function matchHistory(entries, query, limit = HISTORY_SUGGESTIONS) {
14554
+ const needle = query.trim().toLowerCase();
14555
+ return entries.filter((e) => e.url.toLowerCase().includes(needle)).sort(byRank).slice(0, Math.max(0, limit));
14556
+ }
14557
+ const AGENT_ACTIVITY_MS = 3e3;
14558
+ function useAgentActivity() {
14559
+ const [active, setActive] = reactExports.useState(false);
14560
+ reactExports.useEffect(() => {
14561
+ let timer;
14562
+ const off = window.obsrv.onAgentActivity(() => {
14563
+ setActive(true);
14564
+ clearTimeout(timer);
14565
+ timer = setTimeout(() => setActive(false), AGENT_ACTIVITY_MS);
14566
+ });
14567
+ return () => {
14568
+ clearTimeout(timer);
14569
+ off();
14570
+ };
14571
+ }, []);
14572
+ return active;
14573
+ }
14282
14574
  /**
14283
14575
  * @license lucide-react v1.34.0 - ISC
14284
14576
  *
@@ -14406,42 +14698,53 @@ const createLucideIcon = (iconName, iconNode) => {
14406
14698
  * This source code is licensed under the ISC license.
14407
14699
  * See the LICENSE file in the root directory of this source tree.
14408
14700
  */
14409
- const __iconNode$7 = [
14701
+ const __iconNode$8 = [
14410
14702
  ["path", { d: "m12 19-7-7 7-7", key: "1l729n" }],
14411
14703
  ["path", { d: "M19 12H5", key: "x3x0zl" }]
14412
14704
  ];
14413
- const ArrowLeft = createLucideIcon("arrow-left", __iconNode$7);
14705
+ const ArrowLeft = createLucideIcon("arrow-left", __iconNode$8);
14414
14706
  /**
14415
14707
  * @license lucide-react v1.34.0 - ISC
14416
14708
  *
14417
14709
  * This source code is licensed under the ISC license.
14418
14710
  * See the LICENSE file in the root directory of this source tree.
14419
14711
  */
14420
- const __iconNode$6 = [
14712
+ const __iconNode$7 = [
14421
14713
  ["path", { d: "M5 12h14", key: "1ays0h" }],
14422
14714
  ["path", { d: "m12 5 7 7-7 7", key: "xquz4c" }]
14423
14715
  ];
14424
- const ArrowRight = createLucideIcon("arrow-right", __iconNode$6);
14716
+ const ArrowRight = createLucideIcon("arrow-right", __iconNode$7);
14425
14717
  /**
14426
14718
  * @license lucide-react v1.34.0 - ISC
14427
14719
  *
14428
14720
  * This source code is licensed under the ISC license.
14429
14721
  * See the LICENSE file in the root directory of this source tree.
14430
14722
  */
14431
- const __iconNode$5 = [["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }]];
14432
- const ChevronDown = createLucideIcon("chevron-down", __iconNode$5);
14723
+ const __iconNode$6 = [["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }]];
14724
+ const ChevronDown = createLucideIcon("chevron-down", __iconNode$6);
14433
14725
  /**
14434
14726
  * @license lucide-react v1.34.0 - ISC
14435
14727
  *
14436
14728
  * This source code is licensed under the ISC license.
14437
14729
  * See the LICENSE file in the root directory of this source tree.
14438
14730
  */
14439
- const __iconNode$4 = [
14731
+ const __iconNode$5 = [
14440
14732
  ["circle", { cx: "12", cy: "12", r: "1", key: "41hilf" }],
14441
14733
  ["circle", { cx: "12", cy: "5", r: "1", key: "gxeob9" }],
14442
14734
  ["circle", { cx: "12", cy: "19", r: "1", key: "lyex9k" }]
14443
14735
  ];
14444
- const EllipsisVertical = createLucideIcon("ellipsis-vertical", __iconNode$4);
14736
+ const EllipsisVertical = createLucideIcon("ellipsis-vertical", __iconNode$5);
14737
+ /**
14738
+ * @license lucide-react v1.34.0 - ISC
14739
+ *
14740
+ * This source code is licensed under the ISC license.
14741
+ * See the LICENSE file in the root directory of this source tree.
14742
+ */
14743
+ const __iconNode$4 = [
14744
+ ["path", { d: "M5 12h14", key: "1ays0h" }],
14745
+ ["path", { d: "M12 5v14", key: "s699le" }]
14746
+ ];
14747
+ const Plus = createLucideIcon("plus", __iconNode$4);
14445
14748
  /**
14446
14749
  * @license lucide-react v1.34.0 - ISC
14447
14750
  *
@@ -14507,7 +14810,8 @@ const ICONS = {
14507
14810
  close: X,
14508
14811
  sliders: SlidersHorizontal,
14509
14812
  gear: Settings,
14510
- chevron: ChevronDown
14813
+ chevron: ChevronDown,
14814
+ plus: Plus
14511
14815
  };
14512
14816
  function Icon({ name, size = 16 }) {
14513
14817
  const Glyph = ICONS[name];
@@ -14578,6 +14882,77 @@ function Segmented({
14578
14882
  o.id
14579
14883
  )) });
14580
14884
  }
14885
+ function TabBar() {
14886
+ const tabOrder = useStore(useShallow((s) => s.tabOrder));
14887
+ const activeId = useStore((s) => s.activeId);
14888
+ const maxTabs = useStore((s) => s.settings.maxTabs);
14889
+ const driving = useStore((s) => s.settings.agentControl);
14890
+ const agentActive = useAgentActivity();
14891
+ const canAdd = canAddTab(tabOrder.length, maxTabs);
14892
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome-row chrome-tabs", children: [
14893
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tabs", role: "tablist", "aria-label": "Open tabs", children: tabOrder.map((id) => /* @__PURE__ */ jsxRuntimeExports.jsx(
14894
+ Tab,
14895
+ {
14896
+ id,
14897
+ active: id === activeId,
14898
+ driven: driving && id === activeId,
14899
+ busy: agentActive
14900
+ },
14901
+ id
14902
+ )) }),
14903
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14904
+ "button",
14905
+ {
14906
+ className: "tab-new",
14907
+ type: "button",
14908
+ "aria-label": "New tab",
14909
+ disabled: !canAdd,
14910
+ title: canAdd ? "New tab" : `${maxTabs} tabs is the limit — each one is two Chromium renderers, and twelve tabs cost about 2.5 GB. Raise it in Settings.`,
14911
+ onClick: () => {
14912
+ void window.obsrv.addTab();
14913
+ },
14914
+ children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "plus", size: 14 })
14915
+ }
14916
+ )
14917
+ ] });
14918
+ }
14919
+ function Tab({
14920
+ id,
14921
+ active,
14922
+ driven,
14923
+ busy
14924
+ }) {
14925
+ const label = useStore((s) => {
14926
+ const t = s.tabs[id];
14927
+ return t ? tabTitle(t.url, t.title) : "";
14928
+ });
14929
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: `tab${driven ? " driven" : ""}${driven && busy ? " busy" : ""}`, children: [
14930
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14931
+ "button",
14932
+ {
14933
+ className: "tab-label",
14934
+ type: "button",
14935
+ role: "tab",
14936
+ "aria-selected": active,
14937
+ title: driven ? `${label}
14938
+ Agent control is driving this tab` : label,
14939
+ onClick: () => window.obsrv.activateTab(id),
14940
+ children: label
14941
+ }
14942
+ ),
14943
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14944
+ "button",
14945
+ {
14946
+ className: "tab-close",
14947
+ type: "button",
14948
+ "aria-label": `Close ${label}`,
14949
+ title: "Close tab",
14950
+ onClick: () => window.obsrv.closeTab(id),
14951
+ children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "close", size: 12 })
14952
+ }
14953
+ )
14954
+ ] });
14955
+ }
14581
14956
  function Select({ className, value, label, ariaLabel, onChange, children }) {
14582
14957
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "select-shell", children: [
14583
14958
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-label", "aria-hidden": "true", children: label }),
@@ -14608,12 +14983,12 @@ const PANES = [
14608
14983
  { id: "target", label: "Target", title: "The target render alone, full width" }
14609
14984
  ];
14610
14985
  function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14611
- const mode = useStore((s) => s.mode);
14612
- const presetId = useStore((s) => s.presetId);
14613
- const profileId = useStore((s) => s.profileId);
14614
- const pixelExact = useStore((s) => s.pixelExact);
14615
- const error = useStore((s) => s.error);
14616
- const loading = useStore((s) => s.targetLoading);
14986
+ const mode = useStore((s) => selectTab(s).mode);
14987
+ const presetId = useStore((s) => selectTab(s).presetId);
14988
+ const profileId = useStore((s) => selectTab(s).profileId);
14989
+ const pixelExact = useStore((s) => selectTab(s).pixelExact);
14990
+ const error = useStore((s) => selectTab(s).error);
14991
+ const loading = useStore((s) => selectTab(s).targetLoading);
14617
14992
  const barText = useStore(selectUrlBarText);
14618
14993
  const viewport = useStore(useShallow(selectViewport));
14619
14994
  const setUrl = useStore((s) => s.setUrl);
@@ -14625,27 +15000,25 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14625
15000
  const surround = useStore((s) => s.surround);
14626
15001
  const update = useStore((s) => s.update);
14627
15002
  const setSurround = useStore((s) => s.setSurround);
14628
- const viewMode = useStore((s) => s.viewMode);
15003
+ const viewMode = useStore((s) => selectTab(s).viewMode);
14629
15004
  const setViewMode = useStore((s) => s.setViewMode);
14630
15005
  const panes = useStore((s) => s.panes);
14631
15006
  const setPanes = useStore((s) => s.setPanes);
14632
15007
  const agentControl = useStore((s) => s.settings.agentControl);
14633
15008
  const setSettings = useStore((s) => s.setSettings);
15009
+ const history = useStore((s) => s.history);
14634
15010
  const inputRef = reactExports.useRef(null);
14635
15011
  const [draft, setDraft] = reactExports.useState(barText);
14636
- const [agentActive, setAgentActive] = reactExports.useState(false);
14637
- reactExports.useEffect(() => {
14638
- let timer;
14639
- const off = window.obsrv.onAgentActivity(() => {
14640
- setAgentActive(true);
14641
- clearTimeout(timer);
14642
- timer = setTimeout(() => setAgentActive(false), 3e3);
14643
- });
14644
- return () => {
14645
- clearTimeout(timer);
14646
- off();
14647
- };
14648
- }, []);
15012
+ const [open, setOpen] = reactExports.useState(false);
15013
+ const [highlight, setHighlight] = reactExports.useState(-1);
15014
+ const matches = reactExports.useMemo(() => open ? matchHistory(history, draft) : [], [open, history, draft]);
15015
+ const listOpen = open && matches.length > 0;
15016
+ const closeList = () => {
15017
+ setOpen(false);
15018
+ setHighlight(-1);
15019
+ };
15020
+ const picked = highlight >= 0 && highlight < matches.length ? matches[highlight] : null;
15021
+ const agentActive = useAgentActivity();
14649
15022
  const toggleAgent = () => {
14650
15023
  const current = useStore.getState().settings;
14651
15024
  const next = { ...current, agentControl: !current.agentControl };
@@ -14665,17 +15038,54 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14665
15038
  });
14666
15039
  }, []);
14667
15040
  const readOnly = mode === "image";
14668
- const submit = async (e) => {
14669
- e.preventDefault();
14670
- if (readOnly || draft.trim() === "") return;
15041
+ reactExports.useEffect(() => {
15042
+ if (readOnly) closeList();
15043
+ }, [readOnly]);
15044
+ const go = async (url) => {
15045
+ closeList();
14671
15046
  setError(null);
14672
- const applied = await window.obsrv.navigate(draft);
15047
+ const applied = await window.obsrv.navigate(url);
14673
15048
  setUrl(applied);
14674
15049
  setDraft(applied);
14675
15050
  };
15051
+ const submit = (e) => {
15052
+ e.preventDefault();
15053
+ if (readOnly || draft.trim() === "") return;
15054
+ void go(draft);
15055
+ };
15056
+ const onKeyDown = (e) => {
15057
+ if (readOnly) return;
15058
+ if (e.key === "ArrowDown") {
15059
+ e.preventDefault();
15060
+ if (!open) {
15061
+ setOpen(true);
15062
+ setHighlight(0);
15063
+ return;
15064
+ }
15065
+ setHighlight((h) => h + 1 >= matches.length ? -1 : h + 1);
15066
+ return;
15067
+ }
15068
+ if (e.key === "ArrowUp" && listOpen) {
15069
+ e.preventDefault();
15070
+ setHighlight((h) => h <= -1 ? matches.length - 1 : h - 1);
15071
+ return;
15072
+ }
15073
+ if (e.key === "Enter" && picked) {
15074
+ e.preventDefault();
15075
+ void go(picked.url);
15076
+ return;
15077
+ }
15078
+ if (e.key === "Escape") {
15079
+ const dismissed = listOpen;
15080
+ closeList();
15081
+ if (dismissed) return;
15082
+ setDraft(barText);
15083
+ }
15084
+ };
14676
15085
  const presetLabel = SCREEN_PRESETS.find((p) => p.id === presetId)?.label ?? "Custom";
14677
15086
  const profileLabel = PANEL_PROFILES.find((p) => p.id === profileId)?.label ?? profileId;
14678
15087
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome", children: [
15088
+ /* @__PURE__ */ jsxRuntimeExports.jsx(TabBar, {}),
14679
15089
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome-row chrome-browse", children: [
14680
15090
  /* @__PURE__ */ jsxRuntimeExports.jsx(
14681
15091
  "button",
@@ -14710,20 +15120,49 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14710
15120
  children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "reload" })
14711
15121
  }
14712
15122
  ),
14713
- /* @__PURE__ */ jsxRuntimeExports.jsx("form", { className: "url-form", onSubmit: submit, children: /* @__PURE__ */ jsxRuntimeExports.jsx(
14714
- "input",
14715
- {
14716
- ref: inputRef,
14717
- value: draft,
14718
- readOnly,
14719
- spellCheck: false,
14720
- placeholder: "Enter a URL, or drop a PNG",
14721
- onChange: (e) => setDraft(e.target.value),
14722
- onKeyDown: (e) => {
14723
- if (e.key === "Escape") setDraft(barText);
15123
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("form", { className: "url-form", onSubmit: submit, children: [
15124
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
15125
+ "input",
15126
+ {
15127
+ ref: inputRef,
15128
+ value: draft,
15129
+ readOnly,
15130
+ spellCheck: false,
15131
+ placeholder: "Enter a URL, or drop a PNG",
15132
+ role: "combobox",
15133
+ "aria-expanded": listOpen,
15134
+ "aria-controls": "url-history",
15135
+ "aria-autocomplete": "list",
15136
+ "aria-activedescendant": picked ? `url-history-${highlight}` : void 0,
15137
+ onChange: (e) => {
15138
+ setDraft(e.target.value);
15139
+ setOpen(true);
15140
+ setHighlight(-1);
15141
+ },
15142
+ onKeyDown,
15143
+ onBlur: closeList
14724
15144
  }
14725
- }
14726
- ) }),
15145
+ ),
15146
+ listOpen && /* @__PURE__ */ jsxRuntimeExports.jsx("ul", { className: "url-history", id: "url-history", role: "listbox", "aria-label": "Visited addresses", children: matches.map((m, i) => /* @__PURE__ */ jsxRuntimeExports.jsxs(
15147
+ "li",
15148
+ {
15149
+ id: `url-history-${i}`,
15150
+ className: `url-history-row${i === highlight ? " active" : ""}`,
15151
+ role: "option",
15152
+ "aria-selected": i === highlight,
15153
+ onMouseDown: (e) => {
15154
+ e.preventDefault();
15155
+ void go(m.url);
15156
+ },
15157
+ onMouseEnter: () => setHighlight(i),
15158
+ children: [
15159
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "url-history-url", children: /* @__PURE__ */ jsxRuntimeExports.jsx("bdi", { children: m.url }) }),
15160
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "url-history-age", children: formatAge(m.lastVisit, Date.now()) })
15161
+ ]
15162
+ },
15163
+ m.url
15164
+ )) })
15165
+ ] }),
14727
15166
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "status-cluster", children: [
14728
15167
  mode === "image" && /* @__PURE__ */ jsxRuntimeExports.jsx(
14729
15168
  "button",
@@ -14878,50 +15317,71 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14878
15317
  function App() {
14879
15318
  const [fatal, setFatal] = reactExports.useState(null);
14880
15319
  const [drawer, setDrawer] = reactExports.useState("none");
14881
- const [image, setImage] = reactExports.useState(null);
15320
+ const [images, setImages] = reactExports.useState({});
14882
15321
  const dropToken = reactExports.useRef(0);
14883
15322
  const targetPaneRef = reactExports.useRef(null);
14884
15323
  const [targetBounds, setTargetBounds] = reactExports.useState(null);
14885
15324
  const [canvasBounds, setCanvasBounds] = reactExports.useState(null);
15325
+ const [tabsKnown, setTabsKnown] = reactExports.useState(false);
14886
15326
  const toggle = (which) => () => setDrawer((d) => d === which ? "none" : which);
14887
15327
  const setHost = useStore((s) => s.setHost);
14888
15328
  const setSettings = useStore((s) => s.setSettings);
14889
- const setUrl = useStore((s) => s.setUrl);
14890
- const setError = useStore((s) => s.setError);
14891
- const setTargetLoading = useStore((s) => s.setTargetLoading);
15329
+ const setTabUrl = useStore((s) => s.setTabUrl);
15330
+ const setTabTitle = useStore((s) => s.setTabTitle);
15331
+ const setTabError = useStore((s) => s.setTabError);
15332
+ const setTabLoading = useStore((s) => s.setTabLoading);
15333
+ const syncTabs = useStore((s) => s.syncTabs);
14892
15334
  const setUpdate = useStore((s) => s.setUpdate);
15335
+ const setHistory = useStore((s) => s.setHistory);
14893
15336
  const setImageMeta = useStore((s) => s.setImage);
14894
15337
  const setMode = useStore((s) => s.setMode);
14895
15338
  const setToast = useStore((s) => s.setToast);
14896
- const mode = useStore((s) => s.mode);
15339
+ const mode = useStore((s) => selectTab(s).mode);
14897
15340
  const surround = useStore((s) => s.surround);
14898
15341
  const viewport = useStore(useShallow(selectViewport));
14899
15342
  const deviceScaleFactor = useStore(selectDeviceScaleFactor);
14900
- const presetId = useStore((s) => s.presetId);
14901
- const profileId = useStore((s) => s.profileId);
14902
- const viewMode = useStore((s) => s.viewMode);
15343
+ const presetId = useStore((s) => selectTab(s).presetId);
15344
+ const profileId = useStore((s) => selectTab(s).profileId);
15345
+ const viewMode = useStore((s) => selectTab(s).viewMode);
15346
+ const activeId = useStore((s) => s.activeId);
15347
+ const tabOrder = useStore((s) => s.tabOrder);
14903
15348
  const panes = useStore((s) => s.panes);
15349
+ const split = useStore((s) => s.settings.split);
14904
15350
  reactExports.useEffect(() => {
14905
15351
  window.obsrv.getHostInfo().then(setHost, (e) => console.warn("obsrv: getHostInfo failed", e));
14906
15352
  window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
14907
15353
  window.obsrv.getUpdate().then(setUpdate, (e) => console.warn("obsrv: getUpdate failed", e));
15354
+ window.obsrv.getHistory().then(setHistory, (e) => console.warn("obsrv: getHistory failed", e));
15355
+ window.obsrv.getTabs().then(syncTabs, (e) => console.warn("obsrv: getTabs failed", e)).finally(() => setTabsKnown(true));
14908
15356
  const offs = [
14909
15357
  window.obsrv.onHostChanged(setHost),
15358
+ // Every one of these names its tab, so a background tab keeps its own
15359
+ // strip entry current without rewriting the address bar of the tab in
15360
+ // front — which is what an unnamed report from a background tab did, and
15361
+ // why main used to gate them on the tab being in front at all.
15362
+ //
14910
15363
  // A committed navigation — back, forward, reload, a link — supersedes
14911
- // the last load failure. A *failed* load commits Chromium's error page
14912
- // first and reports its error after, so the badge still lands last.
14913
- window.obsrv.onUrlChanged((url) => {
14914
- setError(null);
14915
- setUrl(url);
15364
+ // that tab's last load failure, and only a committed one: Chromium
15365
+ // commits its error page without emitting `did-navigate`, so a failed
15366
+ // load reports no URL at all (measured on Electron 43; a bad host, a
15367
+ // refused connection, a missing file and a Back onto an error page all
15368
+ // fire `did-fail-load` alone). The badge therefore lands last by having
15369
+ // nothing race it.
15370
+ window.obsrv.onUrlChanged(({ tabId, url }) => {
15371
+ setTabError(tabId, null);
15372
+ setTabUrl(tabId, url);
14916
15373
  }),
14917
- window.obsrv.onLoadError(setError),
14918
- window.obsrv.onTargetLoading(setTargetLoading),
14919
- window.obsrv.onUpdateStatus(setUpdate)
15374
+ window.obsrv.onTitleChanged(({ tabId, title }) => setTabTitle(tabId, title)),
15375
+ window.obsrv.onLoadError(({ tabId, error }) => setTabError(tabId, error)),
15376
+ window.obsrv.onTargetLoading(({ tabId, loading }) => setTabLoading(tabId, loading)),
15377
+ window.obsrv.onTabsChanged(syncTabs),
15378
+ window.obsrv.onUpdateStatus(setUpdate),
15379
+ window.obsrv.onHistoryChanged(setHistory)
14920
15380
  ];
14921
15381
  return () => {
14922
15382
  for (const off of offs) off();
14923
15383
  };
14924
- }, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate]);
15384
+ }, [setHost, setSettings, setTabUrl, setTabTitle, setTabError, setTabLoading, syncTabs, setUpdate, setHistory]);
14925
15385
  reactExports.useEffect(() => {
14926
15386
  void window.obsrv.setViewport(viewport.width, viewport.height, deviceScaleFactor);
14927
15387
  }, [viewport.width, viewport.height, deviceScaleFactor]);
@@ -14961,8 +15421,9 @@ function App() {
14961
15421
  };
14962
15422
  }, []);
14963
15423
  reactExports.useEffect(() => {
14964
- window.obsrv.reportUiState({ presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds });
14965
- }, [presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds]);
15424
+ if (!tabsKnown) return;
15425
+ window.obsrv.reportUiState({ tabId: activeId, presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds });
15426
+ }, [tabsKnown, activeId, presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds]);
14966
15427
  reactExports.useEffect(() => {
14967
15428
  return window.obsrv.onAgentApply((patch) => {
14968
15429
  const s = useStore.getState();
@@ -14978,21 +15439,24 @@ function App() {
14978
15439
  reactExports.useEffect(() => {
14979
15440
  document.documentElement.dataset.surround = surround;
14980
15441
  }, [surround]);
15442
+ const image = images[activeId] ?? null;
14981
15443
  const onImage = async (file, exportScale) => {
14982
15444
  const token = ++dropToken.current;
15445
+ const tabId = activeId;
14983
15446
  try {
14984
15447
  const limits = {
14985
15448
  ...DEFAULT_IMAGE_LIMITS,
14986
15449
  maxDimension: Math.min(DEFAULT_IMAGE_LIMITS.maxDimension, probeMaxTextureSize())
14987
15450
  };
14988
15451
  const loaded = await loadImage(file, exportScale, limits);
14989
- if (token !== dropToken.current) {
15452
+ if (token !== dropToken.current || useStore.getState().activeId !== tabId) {
14990
15453
  URL.revokeObjectURL(loaded.objectUrl);
14991
15454
  return;
14992
15455
  }
14993
- setImage((previous) => {
14994
- if (previous) URL.revokeObjectURL(previous.objectUrl);
14995
- return loaded;
15456
+ setImages((previous) => {
15457
+ const stale = previous[tabId];
15458
+ if (stale) URL.revokeObjectURL(stale.objectUrl);
15459
+ return { ...previous, [tabId]: loaded };
14996
15460
  });
14997
15461
  setImageMeta({
14998
15462
  name: file.name,
@@ -15011,11 +15475,28 @@ function App() {
15011
15475
  };
15012
15476
  reactExports.useEffect(() => {
15013
15477
  if (mode === "image") return;
15014
- setImage((previous) => {
15015
- if (previous) URL.revokeObjectURL(previous.objectUrl);
15016
- return null;
15478
+ setImages((previous) => {
15479
+ const going = previous[activeId];
15480
+ if (!going) return previous;
15481
+ URL.revokeObjectURL(going.objectUrl);
15482
+ const next = { ...previous };
15483
+ delete next[activeId];
15484
+ return next;
15017
15485
  });
15018
- }, [mode]);
15486
+ }, [mode, activeId]);
15487
+ reactExports.useEffect(() => {
15488
+ setImages((previous) => {
15489
+ const open = new Set(tabOrder);
15490
+ const gone = Object.keys(previous).filter((id) => !open.has(id));
15491
+ if (gone.length === 0) return previous;
15492
+ const next = { ...previous };
15493
+ for (const id of gone) {
15494
+ URL.revokeObjectURL(next[id].objectUrl);
15495
+ delete next[id];
15496
+ }
15497
+ return next;
15498
+ });
15499
+ }, [tabOrder]);
15019
15500
  const imageFrame = reactExports.useMemo(() => {
15020
15501
  if (mode !== "image" || !image) return null;
15021
15502
  return {
@@ -15031,29 +15512,46 @@ function App() {
15031
15512
  };
15032
15513
  }, [mode, image]);
15033
15514
  if (fatal) return /* @__PURE__ */ jsxRuntimeExports.jsx(Fatal, { message: fatal });
15034
- return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "app", children: [
15035
- /* @__PURE__ */ jsxRuntimeExports.jsx(Toolbar, { drawer, onTogglePanel: toggle("panel"), onToggleSettings: toggle("settings") }),
15036
- /* @__PURE__ */ jsxRuntimeExports.jsx(DropZone, { onImage }),
15037
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "body", children: [
15038
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "panes", "data-panes": panes, children: [
15039
- panes === "both" && (mode === "image" && image ? /* @__PURE__ */ jsxRuntimeExports.jsx(
15040
- ImagePane,
15041
- {
15042
- src: image.objectUrl,
15043
- width: image.natural.width,
15044
- height: image.natural.height
15045
- }
15046
- ) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {})),
15047
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
15048
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
15049
- /* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
15050
- ] })
15051
- ] }),
15052
- drawer === "panel" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PanelControls, {}) }),
15053
- drawer === "settings" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsPanel, {}) })
15054
- ] }),
15055
- /* @__PURE__ */ jsxRuntimeExports.jsx(Toast, {})
15056
- ] });
15515
+ return (
15516
+ // The split and the panes mode are published here rather than on `.panes`
15517
+ // because the chrome needs them too: the URL bar's history dropdown is
15518
+ // clamped to the native pane's right edge, and a custom property set on
15519
+ // `.panes` reaches nothing above it. Everything below still inherits them.
15520
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
15521
+ "div",
15522
+ {
15523
+ className: "app",
15524
+ "data-panes": panes,
15525
+ style: { "--split": split, "--pane-min": `${MIN_PANE_PX}px` },
15526
+ children: [
15527
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Toolbar, { drawer, onTogglePanel: toggle("panel"), onToggleSettings: toggle("settings") }),
15528
+ /* @__PURE__ */ jsxRuntimeExports.jsx(DropZone, { onImage }),
15529
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "body", children: [
15530
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "panes", children: [
15531
+ panes === "both" && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
15532
+ mode === "image" && image ? /* @__PURE__ */ jsxRuntimeExports.jsx(
15533
+ ImagePane,
15534
+ {
15535
+ src: image.objectUrl,
15536
+ width: image.natural.width,
15537
+ height: image.natural.height
15538
+ }
15539
+ ) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {}),
15540
+ /* @__PURE__ */ jsxRuntimeExports.jsx(PaneDivider, {})
15541
+ ] }),
15542
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
15543
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
15544
+ /* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
15545
+ ] })
15546
+ ] }),
15547
+ drawer === "panel" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PanelControls, {}) }),
15548
+ drawer === "settings" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsPanel, {}) })
15549
+ ] }),
15550
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Toast, {})
15551
+ ]
15552
+ }
15553
+ )
15554
+ );
15057
15555
  }
15058
15556
  function BrowserNotice() {
15059
15557
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "browser-notice", role: "note", children: [