getobsrv 0.11.0 → 0.13.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.
@@ -12690,6 +12690,8 @@ const create = ((createState) => createImpl);
12690
12690
  const MAX_VIEWPORT = 4096;
12691
12691
  const SPLIT_MIN = 0.1;
12692
12692
  const SPLIT_MAX = 0.9;
12693
+ const MAX_TABS_MIN = 2;
12694
+ const MAX_TABS_MAX = 32;
12693
12695
  const DEFAULT_SETTINGS = {
12694
12696
  hostDiagonalInches: 27,
12695
12697
  hostNits: 500,
@@ -12697,8 +12699,10 @@ const DEFAULT_SETTINGS = {
12697
12699
  updateCheck: true,
12698
12700
  lastUpdateCheck: 0,
12699
12701
  recordHistory: true,
12700
- split: 0.5
12702
+ split: 0.5,
12703
+ maxTabs: 12
12701
12704
  };
12705
+ const DEFAULT_ORIENTATION = "portrait";
12702
12706
  const SCREEN_PRESETS = [
12703
12707
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
12704
12708
  { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
@@ -12732,6 +12736,13 @@ function findProfile(id) {
12732
12736
  if (!p) throw new Error(`unknown profile: ${id}`);
12733
12737
  return p;
12734
12738
  }
12739
+ function applyOrientation(screen, orientation) {
12740
+ if (orientation !== "landscape") return screen;
12741
+ return { ...screen, width: screen.height, height: screen.width };
12742
+ }
12743
+ function screenShape(width, height) {
12744
+ return width > height ? "landscape" : "portrait";
12745
+ }
12735
12746
  function ppi(width, height, diagonalInches) {
12736
12747
  if (!(diagonalInches > 0)) throw new RangeError("diagonalInches must be > 0");
12737
12748
  return Math.hypot(width, height) / diagonalInches;
@@ -12761,6 +12772,27 @@ function profileToParams(p, hostNits) {
12761
12772
  dither: p.frc
12762
12773
  };
12763
12774
  }
12775
+ function closeTab(tabs, closeId, activeId) {
12776
+ const index = tabs.findIndex((t) => t.id === closeId);
12777
+ if (index === -1) return { tabs, activeId };
12778
+ const next = tabs.filter((t) => t.id !== closeId);
12779
+ if (next.length === 0) return { tabs: next, activeId: null };
12780
+ if (closeId !== activeId) return { tabs: next, activeId };
12781
+ const neighbour = next[Math.min(index, next.length - 1)];
12782
+ return { tabs: next, activeId: neighbour.id };
12783
+ }
12784
+ function canAddTab(count, max) {
12785
+ return count < max;
12786
+ }
12787
+ function tabTitle(url, pageTitle) {
12788
+ if (pageTitle.trim() !== "") return pageTitle;
12789
+ if (url.trim() === "" || url.trim() === "about:blank") return "New tab";
12790
+ try {
12791
+ return new URL(url).host || url;
12792
+ } catch {
12793
+ return url;
12794
+ }
12795
+ }
12764
12796
  const CUSTOM_PRESET_ID = "custom";
12765
12797
  const FALLBACK_SCALE = 2;
12766
12798
  function sameError(a, b) {
@@ -12768,83 +12800,197 @@ function sameError(a, b) {
12768
12800
  if (a === null || b === null) return false;
12769
12801
  return a.code === b.code && a.url === b.url && a.description === b.description;
12770
12802
  }
12771
- const useStore = create()((set) => ({
12772
- mode: "url",
12773
- url: "",
12774
- lastUrl: "",
12775
- presetId: "1080p-24",
12776
- custom: { width: 1920, height: 1080, diagonalInches: 24 },
12777
- pixelExact: false,
12778
- profileId: PANEL_PROFILES[0].id,
12779
- profileOverride: null,
12803
+ function blankTab() {
12804
+ return {
12805
+ mode: "url",
12806
+ url: "",
12807
+ title: "",
12808
+ lastUrl: "",
12809
+ presetId: "1080p-24",
12810
+ orientation: DEFAULT_ORIENTATION,
12811
+ custom: { width: 1920, height: 1080, diagonalInches: 24 },
12812
+ pixelExact: false,
12813
+ profileId: PANEL_PROFILES[0].id,
12814
+ profileOverride: null,
12815
+ targetLoading: false,
12816
+ error: null,
12817
+ image: null,
12818
+ // Fit, not 1:1: fit never enlarges past 1:1, so a render that already fits
12819
+ // its pane opens at true magnification anyway, while one that does not is
12820
+ // shown whole instead of as its top-left corner. Fit is interactive, so
12821
+ // this costs nothing — and the footer names the actual magnification
12822
+ // whenever fit is minifying.
12823
+ viewMode: "fit",
12824
+ fitScale: null,
12825
+ agentPan: null,
12826
+ agentHighlight: null
12827
+ };
12828
+ }
12829
+ let nextTabId = 0;
12830
+ function newTabId() {
12831
+ nextTabId += 1;
12832
+ return `local-${nextTabId}`;
12833
+ }
12834
+ const patchActiveWith = (f) => (s) => {
12835
+ const active = s.tabs[s.activeId];
12836
+ const patch = f(active);
12837
+ return patch === null ? {} : { tabs: { ...s.tabs, [s.activeId]: { ...active, ...patch } } };
12838
+ };
12839
+ const patchActive = (patch) => patchActiveWith(() => patch);
12840
+ const patchTabWith = (id, f) => (s) => {
12841
+ const t = s.tabs[id];
12842
+ if (!t) return {};
12843
+ const patch = f(t);
12844
+ return patch === null ? {} : { tabs: { ...s.tabs, [id]: { ...t, ...patch } } };
12845
+ };
12846
+ const FIRST_TAB = newTabId();
12847
+ const useStore = create()((set, get) => ({
12848
+ tabs: { [FIRST_TAB]: blankTab() },
12849
+ tabOrder: [FIRST_TAB],
12850
+ activeId: FIRST_TAB,
12780
12851
  settings: { ...DEFAULT_SETTINGS },
12781
12852
  // Zeroes until the first `getHostInfo`; `selectScale` falls back meanwhile.
12782
12853
  host: { physicalWidth: 0, physicalHeight: 0, scaleFactor: 0 },
12783
- targetLoading: false,
12784
- error: null,
12785
12854
  toast: null,
12786
12855
  update: null,
12787
12856
  history: [],
12788
- image: null,
12789
12857
  surround: "graphite",
12790
- // Fit, not 1:1: fit never enlarges past 1:1, so a render that already fits
12791
- // its pane opens at true magnification anyway, while one that does not is
12792
- // shown whole instead of as its top-left corner. Fit is interactive, so
12793
- // this costs nothing — and the footer names the actual magnification
12794
- // whenever fit is minifying.
12795
- viewMode: "fit",
12796
12858
  panes: "both",
12797
- fitScale: null,
12798
- agentPan: null,
12799
- agentHighlight: null,
12800
12859
  // Does not clear `error`: a failed load navigates to Chromium's error page,
12801
12860
  // so clearing here would wipe the toolbar badge the moment it appeared.
12802
12861
  // Does clear the agent highlight: it marked pixels of the page that was
12803
12862
  // showing, and a committed navigation (a reload included) replaces them.
12804
- setUrl: (url) => set({ url, agentHighlight: null }),
12863
+ setUrl: (url) => set((s) => patchTabWith(s.activeId, () => ({ url, agentHighlight: null }))(s)),
12864
+ setTabUrl: (id, url) => set(patchTabWith(id, () => ({ url, agentHighlight: null }))),
12865
+ setTabTitle: (id, title) => set(patchTabWith(id, (t) => t.title === title ? null : { title })),
12866
+ // Both panes report the same failure; see `setError`.
12867
+ setTabError: (id, error) => set(patchTabWith(id, (t) => sameError(t.error, error) ? null : { error })),
12868
+ setTabLoading: (id, targetLoading) => set(patchTabWith(id, () => ({ targetLoading }))),
12805
12869
  // A screen change re-rasters the target, so a highlight's target-pixel rect
12806
12870
  // no longer marks what it marked; the same for the custom fields below.
12807
- setPreset: (presetId) => set({ presetId, agentHighlight: null }),
12808
- setCustom: (c) => set((s) => ({ custom: { ...s.custom, ...c }, presetId: CUSTOM_PRESET_ID, agentHighlight: null })),
12809
- setPixelExact: (pixelExact) => set({ pixelExact }),
12871
+ setPreset: (presetId) => set(patchActive({ presetId, agentHighlight: null })),
12872
+ // A rotation re-rasters the target exactly as a preset change does, so a
12873
+ // highlight's target-pixel rect no longer marks what it marked. Setting the
12874
+ // orientation already in force writes nothing, so a re-report from an agent
12875
+ // (or a click on the pressed half of the control) costs no re-render.
12876
+ setOrientation: (orientation) => set(patchActiveWith((t) => t.orientation === orientation ? null : { orientation, agentHighlight: null })),
12877
+ setCustom: (c) => set(patchActiveWith((t) => ({ custom: { ...t.custom, ...c }, presetId: CUSTOM_PRESET_ID, agentHighlight: null }))),
12878
+ setPixelExact: (pixelExact) => set(patchActive({ pixelExact })),
12810
12879
  // Picking a profile drops any hand-tuned slider values.
12811
- setProfile: (profileId) => set({ profileId, profileOverride: null }),
12812
- setProfileOverride: (profileOverride) => set({ profileOverride }),
12880
+ setProfile: (profileId) => set(patchActive({ profileId, profileOverride: null })),
12881
+ setProfileOverride: (profileOverride) => set(patchActive({ profileOverride })),
12813
12882
  setSettings: (settings) => set({ settings }),
12814
12883
  setHost: (host) => set({ host }),
12815
- setTargetLoading: (targetLoading) => set({ targetLoading }),
12884
+ setTargetLoading: (targetLoading) => set(patchActive({ targetLoading })),
12816
12885
  // Both panes report the same `loadError` for one failed navigation; the
12817
12886
  // duplicate must not replace the object and re-render everything twice.
12818
- setError: (error) => set((s) => sameError(s.error, error) ? {} : { error }),
12887
+ setError: (error) => set(patchActiveWith((t) => sameError(t.error, error) ? null : { error })),
12819
12888
  setUpdate: (update) => set({ update }),
12820
12889
  setHistory: (history) => set({ history }),
12821
12890
  setToast: (toast) => set({ toast }),
12822
- setImage: (image) => set({ image }),
12891
+ setImage: (image) => set(patchActive({ image })),
12823
12892
  setSurround: (surround) => set({ surround }),
12824
- setViewMode: (viewMode) => set({ viewMode }),
12893
+ setViewMode: (viewMode) => set(patchActive({ viewMode })),
12825
12894
  // No `agentHighlight: null` here, unlike setPreset: hiding a pane does not
12826
12895
  // re-raster the target, so the highlight still marks the pixels it marked.
12827
12896
  setPanes: (panes) => set({ panes }),
12828
- setFitScale: (fitScale2) => set({ fitScale: fitScale2 }),
12829
- requestAgentPan: (p) => set((s) => ({ agentPan: { ...p, seq: (s.agentPan?.seq ?? 0) + 1 } })),
12830
- clearAgentPan: () => set({ agentPan: null }),
12831
- showAgentHighlight: (h) => set((s) => ({ agentHighlight: { ...h, seq: (s.agentHighlight?.seq ?? 0) + 1 } })),
12832
- clearAgentHighlight: (seq) => set((s) => seq === void 0 || s.agentHighlight?.seq === seq ? { agentHighlight: null } : {}),
12897
+ setFitScale: (fitScale2) => set(patchActive({ fitScale: fitScale2 })),
12898
+ requestAgentPan: (p) => set(patchActiveWith((t) => ({ agentPan: { ...p, seq: (t.agentPan?.seq ?? 0) + 1 } }))),
12899
+ clearAgentPan: () => set(patchActive({ agentPan: null })),
12900
+ showAgentHighlight: (h) => set(patchActiveWith((t) => ({ agentHighlight: { ...h, seq: (t.agentHighlight?.seq ?? 0) + 1 } }))),
12901
+ clearAgentHighlight: (seq) => set(patchActiveWith((t) => seq === void 0 || t.agentHighlight?.seq === seq ? { agentHighlight: null } : null)),
12833
12902
  // Spec §7: leaving image mode restores the URL that was showing before.
12834
12903
  // Either direction swaps what the target pane shows, so a highlight over
12835
12904
  // the old content is dropped with it.
12836
12905
  setMode: (mode) => set(
12837
- (s) => mode === s.mode ? {} : mode === "image" ? { mode, lastUrl: s.url, agentHighlight: null } : { mode, url: s.lastUrl, image: null, agentHighlight: null }
12838
- )
12906
+ patchActiveWith(
12907
+ (t) => mode === t.mode ? null : mode === "image" ? { mode, lastUrl: t.url, agentHighlight: null } : { mode, url: t.lastUrl, image: null, agentHighlight: null }
12908
+ )
12909
+ ),
12910
+ syncTabs: (snap) => set((s) => {
12911
+ if (snap.tabs.length === 0) return {};
12912
+ const tabs = {};
12913
+ for (const info of snap.tabs) {
12914
+ const existing = s.tabs[info.id];
12915
+ tabs[info.id] = existing ? (
12916
+ // url and title are main's to know — it is what every tab's panes
12917
+ // report to — so the snapshot is authoritative for those two and
12918
+ // for nothing else.
12919
+ existing.url === info.url && existing.title === info.title ? existing : { ...existing, url: info.url, title: info.title }
12920
+ ) : (
12921
+ // A tab the renderer has never seen. Its screen comes from the
12922
+ // snapshot rather than from `blankTab`'s defaults, because main
12923
+ // may have restored it from disk with a preset chosen in a
12924
+ // previous launch — and for a tab genuinely opened just now, the
12925
+ // session's own defaults are those same defaults.
12926
+ {
12927
+ ...blankTab(),
12928
+ url: info.url,
12929
+ title: info.title,
12930
+ presetId: info.presetId,
12931
+ profileId: info.profileId,
12932
+ orientation: info.orientation
12933
+ }
12934
+ );
12935
+ }
12936
+ const tabOrder = snap.tabs.map((t) => t.id);
12937
+ return { tabs, tabOrder, activeId: tabs[snap.activeId] ? snap.activeId : tabOrder[0] };
12938
+ }),
12939
+ addTab: (id) => {
12940
+ const s = get();
12941
+ if (!canAddTab(s.tabOrder.length, s.settings.maxTabs)) return null;
12942
+ const next = id ?? newTabId();
12943
+ if (s.tabs[next]) {
12944
+ set({ activeId: next });
12945
+ return next;
12946
+ }
12947
+ set({ tabs: { ...s.tabs, [next]: blankTab() }, tabOrder: [...s.tabOrder, next], activeId: next });
12948
+ return next;
12949
+ },
12950
+ closeTab: (id) => set((s) => {
12951
+ if (!s.tabs[id]) return {};
12952
+ const result = closeTab(
12953
+ s.tabOrder.map((tabId) => ({ id: tabId })),
12954
+ id,
12955
+ s.activeId
12956
+ );
12957
+ if (result.activeId === null) {
12958
+ const fresh = newTabId();
12959
+ return { tabs: { [fresh]: blankTab() }, tabOrder: [fresh], activeId: fresh };
12960
+ }
12961
+ const tabs = { ...s.tabs };
12962
+ delete tabs[id];
12963
+ return { tabs, tabOrder: result.tabs.map((t) => t.id), activeId: result.activeId };
12964
+ }),
12965
+ activateTab: (id) => set((s) => s.tabs[id] ? { activeId: id } : {})
12839
12966
  }));
12840
- function selectScreen(s) {
12841
- const preset = SCREEN_PRESETS.find((p) => p.id === s.presetId);
12967
+ function selectTab(s) {
12968
+ return s.tabs[s.activeId];
12969
+ }
12970
+ function naturalScreen(s) {
12971
+ const tab = selectTab(s);
12972
+ const preset = SCREEN_PRESETS.find((p) => p.id === tab.presetId);
12842
12973
  return preset ? {
12843
12974
  width: preset.width,
12844
12975
  height: preset.height,
12845
12976
  diagonalInches: preset.diagonalInches,
12846
12977
  deviceScaleFactor: preset.deviceScaleFactor
12847
- } : s.custom;
12978
+ } : tab.custom;
12979
+ }
12980
+ function selectScreen(s) {
12981
+ return applyOrientation(naturalScreen(s), selectTab(s).orientation);
12982
+ }
12983
+ function selectScreenShape(s) {
12984
+ const screen = selectScreen(s);
12985
+ return screenShape(screen.width, screen.height);
12986
+ }
12987
+ const ORIENTATIONS = ["portrait", "landscape"];
12988
+ function selectOrientationShapes(s) {
12989
+ const natural = naturalScreen(s);
12990
+ return ORIENTATIONS.map((value) => {
12991
+ const r = applyOrientation(natural, value);
12992
+ return screenShape(r.width, r.height);
12993
+ });
12848
12994
  }
12849
12995
  function selectDeviceScaleFactor(s) {
12850
12996
  return selectScreen(s).deviceScaleFactor ?? 1;
@@ -12864,7 +13010,7 @@ function calibratedScale(s) {
12864
13010
  diagonalInches: s.settings.hostDiagonalInches,
12865
13011
  scaleFactor: s.host.scaleFactor
12866
13012
  };
12867
- const scale = computeScale(host, screen, s.pixelExact);
13013
+ const scale = computeScale(host, screen, selectTab(s).pixelExact);
12868
13014
  return Number.isFinite(scale) && scale > 0 ? scale : null;
12869
13015
  }
12870
13016
  function selectScale(s) {
@@ -12877,13 +13023,15 @@ function selectHostNits(s) {
12877
13023
  return s.settings.hostNits > 0 ? s.settings.hostNits : DEFAULT_SETTINGS.hostNits;
12878
13024
  }
12879
13025
  function selectProfile(s) {
12880
- return s.profileOverride ?? findProfile(s.profileId);
13026
+ const tab = selectTab(s);
13027
+ return tab.profileOverride ?? findProfile(tab.profileId);
12881
13028
  }
12882
13029
  function selectPanelParams(s) {
12883
13030
  return profileToParams(selectProfile(s), selectHostNits(s));
12884
13031
  }
12885
13032
  function selectUrlBarText(s) {
12886
- return s.mode === "image" ? s.image?.name ?? "" : s.url;
13033
+ const tab = selectTab(s);
13034
+ return tab.mode === "image" ? tab.image?.name ?? "" : tab.url;
12887
13035
  }
12888
13036
  const SCALES = [1, 2, 3];
12889
13037
  const DEFAULT_SCALE = 2;
@@ -13060,12 +13208,13 @@ function TargetFooter() {
13060
13208
  const params = useStore(useShallow(selectPanelParams));
13061
13209
  const scale = useStore(selectScale);
13062
13210
  const profile = useStore(selectProfile);
13063
- const image = useStore((s) => s.image);
13064
- const mode = useStore((s) => s.mode);
13065
- const viewMode = useStore((s) => s.viewMode);
13066
- const fitScale2 = useStore((s) => s.fitScale);
13211
+ const image = useStore((s) => selectTab(s).image);
13212
+ const mode = useStore((s) => selectTab(s).mode);
13213
+ const viewMode = useStore((s) => selectTab(s).viewMode);
13214
+ const fitScale2 = useStore((s) => selectTab(s).fitScale);
13067
13215
  const dsf = useStore(selectDeviceScaleFactor);
13068
- const size = mode === "image" && image ? `${image.width}×${image.height}` : `${viewport.width}×${viewport.height}${dsf > 1 ? ` @${dsf}x` : ""}`;
13216
+ const shape = useStore(selectScreenShape);
13217
+ const size = mode === "image" && image ? `${image.width}×${image.height}` : `${viewport.width}×${viewport.height}${dsf > 1 ? ` @${dsf}x` : ""} ${shape}`;
13069
13218
  const depth = params.levels <= 63 ? "6-bit" : "8-bit";
13070
13219
  const magnification = viewMode === "fit" && fitScale2 !== null ? [`fit ×${fitScale2.toFixed(2)}`, "not pixel-exact"] : [`×${scale.toFixed(2)}`];
13071
13220
  return /* @__PURE__ */ jsxRuntimeExports.jsx(
@@ -13407,7 +13556,8 @@ function SettingsPanel() {
13407
13556
  const settings = useStore(useShallow((s) => s.settings));
13408
13557
  const update = useStore((s) => s.update);
13409
13558
  const history = useStore((s) => s.history);
13410
- const custom = useStore(useShallow((s) => s.custom));
13559
+ const custom = useStore(useShallow((s) => selectTab(s).custom));
13560
+ const orientation = useStore((s) => selectTab(s).orientation);
13411
13561
  const viewport = useStore(useShallow(selectViewport));
13412
13562
  const scale = useStore(selectScale);
13413
13563
  const fallback = useStore(selectScaleIsFallback);
@@ -13480,6 +13630,15 @@ function SettingsPanel() {
13480
13630
  ] }),
13481
13631
  /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "Custom screen" }),
13482
13632
  /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Editing these selects the Custom preset." }),
13633
+ orientation === "landscape" && /* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "muted custom-rotated", children: [
13634
+ "Rotated, so these render transposed:",
13635
+ " ",
13636
+ (() => {
13637
+ const r = applyOrientation({ ...custom }, orientation);
13638
+ return `${r.width}×${r.height}`;
13639
+ })(),
13640
+ "."
13641
+ ] }),
13483
13642
  /* @__PURE__ */ jsxRuntimeExports.jsx(
13484
13643
  NumberField,
13485
13644
  {
@@ -13564,6 +13723,24 @@ function SettingsPanel() {
13564
13723
  ] }),
13565
13724
  /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "check-now", onClick: () => void window.obsrv.checkUpdate(), children: "Check now" }),
13566
13725
  /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." }),
13726
+ /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "Tabs" }),
13727
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13728
+ NumberField,
13729
+ {
13730
+ className: "max-tabs",
13731
+ label: "Maximum tabs",
13732
+ unit: "tabs",
13733
+ value: settings.maxTabs,
13734
+ min: MAX_TABS_MIN,
13735
+ step: 1,
13736
+ onCommit: (v) => commit({
13737
+ ...useStore.getState().settings,
13738
+ maxTabs: Math.min(MAX_TABS_MAX, Math.max(MAX_TABS_MIN, Math.round(v)))
13739
+ }),
13740
+ onInvalid: setHostError
13741
+ }
13742
+ ),
13743
+ /* @__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." }),
13567
13744
  /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "History" }),
13568
13745
  /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "control inline record-history-toggle", children: [
13569
13746
  /* @__PURE__ */ jsxRuntimeExports.jsx(
@@ -14036,10 +14213,11 @@ function TargetCanvas({ onFatal, imageFrame }) {
14036
14213
  const params = useStore(useShallow(selectPanelParams));
14037
14214
  const dsf = useStore(selectDeviceScaleFactor);
14038
14215
  const requestedScale = useStore(selectScale);
14039
- const mode = useStore((s) => s.mode);
14040
- const viewMode = useStore((s) => s.viewMode);
14216
+ const mode = useStore((s) => selectTab(s).mode);
14217
+ const viewMode = useStore((s) => selectTab(s).viewMode);
14041
14218
  const setViewMode = useStore((s) => s.setViewMode);
14042
14219
  const setFitScale = useStore((s) => s.setFitScale);
14220
+ const activeId = useStore((s) => s.activeId);
14043
14221
  const [stalled, setStalled] = reactExports.useState(false);
14044
14222
  const stallTimer = reactExports.useRef(0);
14045
14223
  const armedOnce = reactExports.useRef(false);
@@ -14071,7 +14249,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14071
14249
  const smooth = fit || scale < 1;
14072
14250
  reactExports.useEffect(() => {
14073
14251
  setFitScale(fit ? scale : null);
14074
- }, [fit, scale, setFitScale]);
14252
+ }, [fit, scale, activeId, setFitScale]);
14075
14253
  reactExports.useEffect(() => () => setFitScale(null), [setFitScale]);
14076
14254
  const draw = reactExports.useRef({ scale, params, smooth, dsf });
14077
14255
  const imageRef = reactExports.useRef(imageFrame);
@@ -14110,7 +14288,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14110
14288
  offFrame = window.obsrv.onFrame((m) => {
14111
14289
  disarm();
14112
14290
  if (!gl) return;
14113
- if (useStore.getState().mode !== "url") return;
14291
+ if (selectTab(useStore.getState()).mode !== "url") return;
14114
14292
  gl.resizeSource(m.frameWidth, m.frameHeight);
14115
14293
  gl.uploadSlice(m.frame);
14116
14294
  schedule();
@@ -14138,7 +14316,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14138
14316
  const onWheel = (e) => {
14139
14317
  if (e.altKey) {
14140
14318
  e.preventDefault();
14141
- if (useStore.getState().viewMode !== "1:1") return;
14319
+ if (selectTab(useStore.getState()).viewMode !== "1:1") return;
14142
14320
  const body = canvas.closest(".pane-body");
14143
14321
  if (body instanceof HTMLElement) {
14144
14322
  body.scrollLeft += e.deltaX;
@@ -14146,7 +14324,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14146
14324
  }
14147
14325
  return;
14148
14326
  }
14149
- if (useStore.getState().mode !== "url") return;
14327
+ if (selectTab(useStore.getState()).mode !== "url") return;
14150
14328
  e.preventDefault();
14151
14329
  const r = canvas.getBoundingClientRect();
14152
14330
  const cssPerTarget = draw.current.scale * draw.current.dsf / (window.devicePixelRatio || 1);
@@ -14155,7 +14333,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14155
14333
  };
14156
14334
  canvas.addEventListener("wheel", onWheel, { passive: false });
14157
14335
  const onWindowUp = (e) => {
14158
- if (useStore.getState().mode !== "url") return;
14336
+ if (selectTab(useStore.getState()).mode !== "url") return;
14159
14337
  if (panRef.current || e.button === 1) return;
14160
14338
  if (e.altKey && !forwardDrag.current) return;
14161
14339
  if (e.target === canvas) return;
@@ -14178,8 +14356,10 @@ function TargetCanvas({ onFatal, imageFrame }) {
14178
14356
  };
14179
14357
  }, [onFatal, disarm]);
14180
14358
  reactExports.useEffect(
14181
- () => window.obsrv.onTargetNavigating(() => {
14182
- if (useStore.getState().mode === "url") arm();
14359
+ () => window.obsrv.onTargetNavigating(({ tabId }) => {
14360
+ const s = useStore.getState();
14361
+ if (tabId !== s.activeId) return;
14362
+ if (selectTab(s).mode === "url") arm();
14183
14363
  }),
14184
14364
  [arm]
14185
14365
  );
@@ -14293,7 +14473,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14293
14473
  );
14294
14474
  setViewMode("1:1");
14295
14475
  };
14296
- const agentPan = useStore((s) => s.agentPan);
14476
+ const agentPan = useStore((s) => selectTab(s).agentPan);
14297
14477
  const clearAgentPan = useStore((s) => s.clearAgentPan);
14298
14478
  reactExports.useEffect(() => {
14299
14479
  if (!agentPan) return;
@@ -14310,7 +14490,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14310
14490
  body.scrollTop = jump.top;
14311
14491
  }
14312
14492
  }, [agentPan]);
14313
- const agentHighlight = useStore((s) => s.agentHighlight);
14493
+ const agentHighlight = useStore((s) => selectTab(s).agentHighlight);
14314
14494
  const clearAgentHighlight = useStore((s) => s.clearAgentHighlight);
14315
14495
  reactExports.useEffect(() => {
14316
14496
  if (!agentHighlight) return;
@@ -14415,6 +14595,23 @@ function matchHistory(entries, query, limit = HISTORY_SUGGESTIONS) {
14415
14595
  const needle = query.trim().toLowerCase();
14416
14596
  return entries.filter((e) => e.url.toLowerCase().includes(needle)).sort(byRank).slice(0, Math.max(0, limit));
14417
14597
  }
14598
+ const AGENT_ACTIVITY_MS = 3e3;
14599
+ function useAgentActivity() {
14600
+ const [active, setActive] = reactExports.useState(false);
14601
+ reactExports.useEffect(() => {
14602
+ let timer;
14603
+ const off = window.obsrv.onAgentActivity(() => {
14604
+ setActive(true);
14605
+ clearTimeout(timer);
14606
+ timer = setTimeout(() => setActive(false), AGENT_ACTIVITY_MS);
14607
+ });
14608
+ return () => {
14609
+ clearTimeout(timer);
14610
+ off();
14611
+ };
14612
+ }, []);
14613
+ return active;
14614
+ }
14418
14615
  /**
14419
14616
  * @license lucide-react v1.34.0 - ISC
14420
14617
  *
@@ -14542,42 +14739,73 @@ const createLucideIcon = (iconName, iconNode) => {
14542
14739
  * This source code is licensed under the ISC license.
14543
14740
  * See the LICENSE file in the root directory of this source tree.
14544
14741
  */
14545
- const __iconNode$7 = [
14742
+ const __iconNode$a = [
14546
14743
  ["path", { d: "m12 19-7-7 7-7", key: "1l729n" }],
14547
14744
  ["path", { d: "M19 12H5", key: "x3x0zl" }]
14548
14745
  ];
14549
- const ArrowLeft = createLucideIcon("arrow-left", __iconNode$7);
14746
+ const ArrowLeft = createLucideIcon("arrow-left", __iconNode$a);
14550
14747
  /**
14551
14748
  * @license lucide-react v1.34.0 - ISC
14552
14749
  *
14553
14750
  * This source code is licensed under the ISC license.
14554
14751
  * See the LICENSE file in the root directory of this source tree.
14555
14752
  */
14556
- const __iconNode$6 = [
14753
+ const __iconNode$9 = [
14557
14754
  ["path", { d: "M5 12h14", key: "1ays0h" }],
14558
14755
  ["path", { d: "m12 5 7 7-7 7", key: "xquz4c" }]
14559
14756
  ];
14560
- const ArrowRight = createLucideIcon("arrow-right", __iconNode$6);
14757
+ const ArrowRight = createLucideIcon("arrow-right", __iconNode$9);
14561
14758
  /**
14562
14759
  * @license lucide-react v1.34.0 - ISC
14563
14760
  *
14564
14761
  * This source code is licensed under the ISC license.
14565
14762
  * See the LICENSE file in the root directory of this source tree.
14566
14763
  */
14567
- const __iconNode$5 = [["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }]];
14568
- const ChevronDown = createLucideIcon("chevron-down", __iconNode$5);
14764
+ const __iconNode$8 = [["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }]];
14765
+ const ChevronDown = createLucideIcon("chevron-down", __iconNode$8);
14569
14766
  /**
14570
14767
  * @license lucide-react v1.34.0 - ISC
14571
14768
  *
14572
14769
  * This source code is licensed under the ISC license.
14573
14770
  * See the LICENSE file in the root directory of this source tree.
14574
14771
  */
14575
- const __iconNode$4 = [
14772
+ const __iconNode$7 = [
14576
14773
  ["circle", { cx: "12", cy: "12", r: "1", key: "41hilf" }],
14577
14774
  ["circle", { cx: "12", cy: "5", r: "1", key: "gxeob9" }],
14578
14775
  ["circle", { cx: "12", cy: "19", r: "1", key: "lyex9k" }]
14579
14776
  ];
14580
- const EllipsisVertical = createLucideIcon("ellipsis-vertical", __iconNode$4);
14777
+ const EllipsisVertical = createLucideIcon("ellipsis-vertical", __iconNode$7);
14778
+ /**
14779
+ * @license lucide-react v1.34.0 - ISC
14780
+ *
14781
+ * This source code is licensed under the ISC license.
14782
+ * See the LICENSE file in the root directory of this source tree.
14783
+ */
14784
+ const __iconNode$6 = [
14785
+ ["path", { d: "M5 12h14", key: "1ays0h" }],
14786
+ ["path", { d: "M12 5v14", key: "s699le" }]
14787
+ ];
14788
+ const Plus = createLucideIcon("plus", __iconNode$6);
14789
+ /**
14790
+ * @license lucide-react v1.34.0 - ISC
14791
+ *
14792
+ * This source code is licensed under the ISC license.
14793
+ * See the LICENSE file in the root directory of this source tree.
14794
+ */
14795
+ const __iconNode$5 = [
14796
+ ["rect", { width: "20", height: "12", x: "2", y: "6", rx: "2", key: "9lu3g6" }]
14797
+ ];
14798
+ const RectangleHorizontal = createLucideIcon("rectangle-horizontal", __iconNode$5);
14799
+ /**
14800
+ * @license lucide-react v1.34.0 - ISC
14801
+ *
14802
+ * This source code is licensed under the ISC license.
14803
+ * See the LICENSE file in the root directory of this source tree.
14804
+ */
14805
+ const __iconNode$4 = [
14806
+ ["rect", { width: "12", height: "20", x: "6", y: "2", rx: "2", key: "1oxtiu" }]
14807
+ ];
14808
+ const RectangleVertical = createLucideIcon("rectangle-vertical", __iconNode$4);
14581
14809
  /**
14582
14810
  * @license lucide-react v1.34.0 - ISC
14583
14811
  *
@@ -14643,7 +14871,13 @@ const ICONS = {
14643
14871
  close: X,
14644
14872
  sliders: SlidersHorizontal,
14645
14873
  gear: Settings,
14646
- chevron: ChevronDown
14874
+ chevron: ChevronDown,
14875
+ plus: Plus,
14876
+ // The rotate control's two shapes. A plain outline of the screen you get is
14877
+ // the whole affordance — no arrow, no device silhouette: the target may be a
14878
+ // monitor as readily as a phone.
14879
+ portrait: RectangleVertical,
14880
+ landscape: RectangleHorizontal
14647
14881
  };
14648
14882
  function Icon({ name, size = 16 }) {
14649
14883
  const Glyph = ICONS[name];
@@ -14714,6 +14948,77 @@ function Segmented({
14714
14948
  o.id
14715
14949
  )) });
14716
14950
  }
14951
+ function TabBar() {
14952
+ const tabOrder = useStore(useShallow((s) => s.tabOrder));
14953
+ const activeId = useStore((s) => s.activeId);
14954
+ const maxTabs = useStore((s) => s.settings.maxTabs);
14955
+ const driving = useStore((s) => s.settings.agentControl);
14956
+ const agentActive = useAgentActivity();
14957
+ const canAdd = canAddTab(tabOrder.length, maxTabs);
14958
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome-row chrome-tabs", children: [
14959
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "tabs", role: "tablist", "aria-label": "Open tabs", children: tabOrder.map((id) => /* @__PURE__ */ jsxRuntimeExports.jsx(
14960
+ Tab,
14961
+ {
14962
+ id,
14963
+ active: id === activeId,
14964
+ driven: driving && id === activeId,
14965
+ busy: agentActive
14966
+ },
14967
+ id
14968
+ )) }),
14969
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14970
+ "button",
14971
+ {
14972
+ className: "tab-new",
14973
+ type: "button",
14974
+ "aria-label": "New tab",
14975
+ disabled: !canAdd,
14976
+ 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.`,
14977
+ onClick: () => {
14978
+ void window.obsrv.addTab();
14979
+ },
14980
+ children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "plus", size: 14 })
14981
+ }
14982
+ )
14983
+ ] });
14984
+ }
14985
+ function Tab({
14986
+ id,
14987
+ active,
14988
+ driven,
14989
+ busy
14990
+ }) {
14991
+ const label = useStore((s) => {
14992
+ const t = s.tabs[id];
14993
+ return t ? tabTitle(t.url, t.title) : "";
14994
+ });
14995
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: `tab${driven ? " driven" : ""}${driven && busy ? " busy" : ""}`, children: [
14996
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14997
+ "button",
14998
+ {
14999
+ className: "tab-label",
15000
+ type: "button",
15001
+ role: "tab",
15002
+ "aria-selected": active,
15003
+ title: driven ? `${label}
15004
+ Agent control is driving this tab` : label,
15005
+ onClick: () => window.obsrv.activateTab(id),
15006
+ children: label
15007
+ }
15008
+ ),
15009
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
15010
+ "button",
15011
+ {
15012
+ className: "tab-close",
15013
+ type: "button",
15014
+ "aria-label": `Close ${label}`,
15015
+ title: "Close tab",
15016
+ onClick: () => window.obsrv.closeTab(id),
15017
+ children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "close", size: 12 })
15018
+ }
15019
+ )
15020
+ ] });
15021
+ }
14717
15022
  function Select({ className, value, label, ariaLabel, onChange, children }) {
14718
15023
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "select-shell", children: [
14719
15024
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-label", "aria-hidden": "true", children: label }),
@@ -14744,24 +15049,27 @@ const PANES = [
14744
15049
  { id: "target", label: "Target", title: "The target render alone, full width" }
14745
15050
  ];
14746
15051
  function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14747
- const mode = useStore((s) => s.mode);
14748
- const presetId = useStore((s) => s.presetId);
14749
- const profileId = useStore((s) => s.profileId);
14750
- const pixelExact = useStore((s) => s.pixelExact);
14751
- const error = useStore((s) => s.error);
14752
- const loading = useStore((s) => s.targetLoading);
15052
+ const mode = useStore((s) => selectTab(s).mode);
15053
+ const presetId = useStore((s) => selectTab(s).presetId);
15054
+ const profileId = useStore((s) => selectTab(s).profileId);
15055
+ const pixelExact = useStore((s) => selectTab(s).pixelExact);
15056
+ const error = useStore((s) => selectTab(s).error);
15057
+ const loading = useStore((s) => selectTab(s).targetLoading);
14753
15058
  const barText = useStore(selectUrlBarText);
14754
15059
  const viewport = useStore(useShallow(selectViewport));
14755
15060
  const setUrl = useStore((s) => s.setUrl);
14756
15061
  const setMode = useStore((s) => s.setMode);
14757
15062
  const setPreset = useStore((s) => s.setPreset);
15063
+ const orientation = useStore((s) => selectTab(s).orientation);
15064
+ const orientationShapes = useStore(useShallow(selectOrientationShapes));
15065
+ const setOrientation = useStore((s) => s.setOrientation);
14758
15066
  const setProfile = useStore((s) => s.setProfile);
14759
15067
  const setPixelExact = useStore((s) => s.setPixelExact);
14760
15068
  const setError = useStore((s) => s.setError);
14761
15069
  const surround = useStore((s) => s.surround);
14762
15070
  const update = useStore((s) => s.update);
14763
15071
  const setSurround = useStore((s) => s.setSurround);
14764
- const viewMode = useStore((s) => s.viewMode);
15072
+ const viewMode = useStore((s) => selectTab(s).viewMode);
14765
15073
  const setViewMode = useStore((s) => s.setViewMode);
14766
15074
  const panes = useStore((s) => s.panes);
14767
15075
  const setPanes = useStore((s) => s.setPanes);
@@ -14779,19 +15087,7 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14779
15087
  setHighlight(-1);
14780
15088
  };
14781
15089
  const picked = highlight >= 0 && highlight < matches.length ? matches[highlight] : null;
14782
- const [agentActive, setAgentActive] = reactExports.useState(false);
14783
- reactExports.useEffect(() => {
14784
- let timer;
14785
- const off = window.obsrv.onAgentActivity(() => {
14786
- setAgentActive(true);
14787
- clearTimeout(timer);
14788
- timer = setTimeout(() => setAgentActive(false), 3e3);
14789
- });
14790
- return () => {
14791
- clearTimeout(timer);
14792
- off();
14793
- };
14794
- }, []);
15090
+ const agentActive = useAgentActivity();
14795
15091
  const toggleAgent = () => {
14796
15092
  const current = useStore.getState().settings;
14797
15093
  const next = { ...current, agentControl: !current.agentControl };
@@ -14858,6 +15154,7 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14858
15154
  const presetLabel = SCREEN_PRESETS.find((p) => p.id === presetId)?.label ?? "Custom";
14859
15155
  const profileLabel = PANEL_PROFILES.find((p) => p.id === profileId)?.label ?? profileId;
14860
15156
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome", children: [
15157
+ /* @__PURE__ */ jsxRuntimeExports.jsx(TabBar, {}),
14861
15158
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome-row chrome-browse", children: [
14862
15159
  /* @__PURE__ */ jsxRuntimeExports.jsx(
14863
15160
  "button",
@@ -15038,6 +15335,23 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
15038
15335
  ]
15039
15336
  }
15040
15337
  ),
15338
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "orientation-control", role: "group", "aria-label": "Screen orientation", children: ORIENTATIONS.map((value, i) => {
15339
+ const shape = orientationShapes[i] ?? value;
15340
+ const name = shape === "landscape" ? "Landscape" : "Portrait";
15341
+ return /* @__PURE__ */ jsxRuntimeExports.jsx(
15342
+ "button",
15343
+ {
15344
+ type: "button",
15345
+ className: `orient-${shape}`,
15346
+ title: `${name} — ${value === orientation ? "showing" : "rotate the screen"}`,
15347
+ "aria-label": name,
15348
+ "aria-pressed": orientation === value,
15349
+ onClick: () => setOrientation(value),
15350
+ children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: shape })
15351
+ },
15352
+ value
15353
+ );
15354
+ }) }),
15041
15355
  /* @__PURE__ */ jsxRuntimeExports.jsx(
15042
15356
  Segmented,
15043
15357
  {
@@ -15089,29 +15403,35 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
15089
15403
  function App() {
15090
15404
  const [fatal, setFatal] = reactExports.useState(null);
15091
15405
  const [drawer, setDrawer] = reactExports.useState("none");
15092
- const [image, setImage] = reactExports.useState(null);
15406
+ const [images, setImages] = reactExports.useState({});
15093
15407
  const dropToken = reactExports.useRef(0);
15094
15408
  const targetPaneRef = reactExports.useRef(null);
15095
15409
  const [targetBounds, setTargetBounds] = reactExports.useState(null);
15096
15410
  const [canvasBounds, setCanvasBounds] = reactExports.useState(null);
15411
+ const [tabsKnown, setTabsKnown] = reactExports.useState(false);
15097
15412
  const toggle = (which) => () => setDrawer((d) => d === which ? "none" : which);
15098
15413
  const setHost = useStore((s) => s.setHost);
15099
15414
  const setSettings = useStore((s) => s.setSettings);
15100
- const setUrl = useStore((s) => s.setUrl);
15101
- const setError = useStore((s) => s.setError);
15102
- const setTargetLoading = useStore((s) => s.setTargetLoading);
15415
+ const setTabUrl = useStore((s) => s.setTabUrl);
15416
+ const setTabTitle = useStore((s) => s.setTabTitle);
15417
+ const setTabError = useStore((s) => s.setTabError);
15418
+ const setTabLoading = useStore((s) => s.setTabLoading);
15419
+ const syncTabs = useStore((s) => s.syncTabs);
15103
15420
  const setUpdate = useStore((s) => s.setUpdate);
15104
15421
  const setHistory = useStore((s) => s.setHistory);
15105
15422
  const setImageMeta = useStore((s) => s.setImage);
15106
15423
  const setMode = useStore((s) => s.setMode);
15107
15424
  const setToast = useStore((s) => s.setToast);
15108
- const mode = useStore((s) => s.mode);
15425
+ const mode = useStore((s) => selectTab(s).mode);
15109
15426
  const surround = useStore((s) => s.surround);
15110
15427
  const viewport = useStore(useShallow(selectViewport));
15111
15428
  const deviceScaleFactor = useStore(selectDeviceScaleFactor);
15112
- const presetId = useStore((s) => s.presetId);
15113
- const profileId = useStore((s) => s.profileId);
15114
- const viewMode = useStore((s) => s.viewMode);
15429
+ const presetId = useStore((s) => selectTab(s).presetId);
15430
+ const profileId = useStore((s) => selectTab(s).profileId);
15431
+ const orientation = useStore((s) => selectTab(s).orientation);
15432
+ const viewMode = useStore((s) => selectTab(s).viewMode);
15433
+ const activeId = useStore((s) => s.activeId);
15434
+ const tabOrder = useStore((s) => s.tabOrder);
15115
15435
  const panes = useStore((s) => s.panes);
15116
15436
  const split = useStore((s) => s.settings.split);
15117
15437
  reactExports.useEffect(() => {
@@ -15119,28 +15439,36 @@ function App() {
15119
15439
  window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
15120
15440
  window.obsrv.getUpdate().then(setUpdate, (e) => console.warn("obsrv: getUpdate failed", e));
15121
15441
  window.obsrv.getHistory().then(setHistory, (e) => console.warn("obsrv: getHistory failed", e));
15442
+ window.obsrv.getTabs().then(syncTabs, (e) => console.warn("obsrv: getTabs failed", e)).finally(() => setTabsKnown(true));
15122
15443
  const offs = [
15123
15444
  window.obsrv.onHostChanged(setHost),
15445
+ // Every one of these names its tab, so a background tab keeps its own
15446
+ // strip entry current without rewriting the address bar of the tab in
15447
+ // front — which is what an unnamed report from a background tab did, and
15448
+ // why main used to gate them on the tab being in front at all.
15449
+ //
15124
15450
  // A committed navigation — back, forward, reload, a link — supersedes
15125
- // the last load failure, and only a committed one: Chromium commits its
15126
- // error page without emitting `did-navigate`, so a failed load reports
15127
- // no URL at all (measured on Electron 43; a bad host, a refused
15128
- // connection, a missing file and a Back onto an error page all fire
15129
- // `did-fail-load` alone). The badge therefore lands last by having
15451
+ // that tab's last load failure, and only a committed one: Chromium
15452
+ // commits its error page without emitting `did-navigate`, so a failed
15453
+ // load reports no URL at all (measured on Electron 43; a bad host, a
15454
+ // refused connection, a missing file and a Back onto an error page all
15455
+ // fire `did-fail-load` alone). The badge therefore lands last by having
15130
15456
  // nothing race it.
15131
- window.obsrv.onUrlChanged((url) => {
15132
- setError(null);
15133
- setUrl(url);
15457
+ window.obsrv.onUrlChanged(({ tabId, url }) => {
15458
+ setTabError(tabId, null);
15459
+ setTabUrl(tabId, url);
15134
15460
  }),
15135
- window.obsrv.onLoadError(setError),
15136
- window.obsrv.onTargetLoading(setTargetLoading),
15461
+ window.obsrv.onTitleChanged(({ tabId, title }) => setTabTitle(tabId, title)),
15462
+ window.obsrv.onLoadError(({ tabId, error }) => setTabError(tabId, error)),
15463
+ window.obsrv.onTargetLoading(({ tabId, loading }) => setTabLoading(tabId, loading)),
15464
+ window.obsrv.onTabsChanged(syncTabs),
15137
15465
  window.obsrv.onUpdateStatus(setUpdate),
15138
15466
  window.obsrv.onHistoryChanged(setHistory)
15139
15467
  ];
15140
15468
  return () => {
15141
15469
  for (const off of offs) off();
15142
15470
  };
15143
- }, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate, setHistory]);
15471
+ }, [setHost, setSettings, setTabUrl, setTabTitle, setTabError, setTabLoading, syncTabs, setUpdate, setHistory]);
15144
15472
  reactExports.useEffect(() => {
15145
15473
  void window.obsrv.setViewport(viewport.width, viewport.height, deviceScaleFactor);
15146
15474
  }, [viewport.width, viewport.height, deviceScaleFactor]);
@@ -15180,13 +15508,15 @@ function App() {
15180
15508
  };
15181
15509
  }, []);
15182
15510
  reactExports.useEffect(() => {
15183
- window.obsrv.reportUiState({ presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds });
15184
- }, [presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds]);
15511
+ if (!tabsKnown) return;
15512
+ window.obsrv.reportUiState({ tabId: activeId, presetId, profileId, orientation, viewMode, panes, mode, targetBounds, canvasBounds });
15513
+ }, [tabsKnown, activeId, presetId, profileId, orientation, viewMode, panes, mode, targetBounds, canvasBounds]);
15185
15514
  reactExports.useEffect(() => {
15186
15515
  return window.obsrv.onAgentApply((patch) => {
15187
15516
  const s = useStore.getState();
15188
15517
  if (patch.presetId !== void 0) s.setPreset(patch.presetId);
15189
15518
  if (patch.profileId !== void 0) s.setProfile(patch.profileId);
15519
+ if (patch.orientation !== void 0) s.setOrientation(patch.orientation);
15190
15520
  if (patch.viewMode !== void 0) s.setViewMode(patch.viewMode);
15191
15521
  if (patch.panes !== void 0) s.setPanes(patch.panes);
15192
15522
  if (patch.pixelExact !== void 0) s.setPixelExact(patch.pixelExact);
@@ -15197,21 +15527,24 @@ function App() {
15197
15527
  reactExports.useEffect(() => {
15198
15528
  document.documentElement.dataset.surround = surround;
15199
15529
  }, [surround]);
15530
+ const image = images[activeId] ?? null;
15200
15531
  const onImage = async (file, exportScale) => {
15201
15532
  const token = ++dropToken.current;
15533
+ const tabId = activeId;
15202
15534
  try {
15203
15535
  const limits = {
15204
15536
  ...DEFAULT_IMAGE_LIMITS,
15205
15537
  maxDimension: Math.min(DEFAULT_IMAGE_LIMITS.maxDimension, probeMaxTextureSize())
15206
15538
  };
15207
15539
  const loaded = await loadImage(file, exportScale, limits);
15208
- if (token !== dropToken.current) {
15540
+ if (token !== dropToken.current || useStore.getState().activeId !== tabId) {
15209
15541
  URL.revokeObjectURL(loaded.objectUrl);
15210
15542
  return;
15211
15543
  }
15212
- setImage((previous) => {
15213
- if (previous) URL.revokeObjectURL(previous.objectUrl);
15214
- return loaded;
15544
+ setImages((previous) => {
15545
+ const stale = previous[tabId];
15546
+ if (stale) URL.revokeObjectURL(stale.objectUrl);
15547
+ return { ...previous, [tabId]: loaded };
15215
15548
  });
15216
15549
  setImageMeta({
15217
15550
  name: file.name,
@@ -15230,11 +15563,28 @@ function App() {
15230
15563
  };
15231
15564
  reactExports.useEffect(() => {
15232
15565
  if (mode === "image") return;
15233
- setImage((previous) => {
15234
- if (previous) URL.revokeObjectURL(previous.objectUrl);
15235
- return null;
15566
+ setImages((previous) => {
15567
+ const going = previous[activeId];
15568
+ if (!going) return previous;
15569
+ URL.revokeObjectURL(going.objectUrl);
15570
+ const next = { ...previous };
15571
+ delete next[activeId];
15572
+ return next;
15236
15573
  });
15237
- }, [mode]);
15574
+ }, [mode, activeId]);
15575
+ reactExports.useEffect(() => {
15576
+ setImages((previous) => {
15577
+ const open = new Set(tabOrder);
15578
+ const gone = Object.keys(previous).filter((id) => !open.has(id));
15579
+ if (gone.length === 0) return previous;
15580
+ const next = { ...previous };
15581
+ for (const id of gone) {
15582
+ URL.revokeObjectURL(next[id].objectUrl);
15583
+ delete next[id];
15584
+ }
15585
+ return next;
15586
+ });
15587
+ }, [tabOrder]);
15238
15588
  const imageFrame = reactExports.useMemo(() => {
15239
15589
  if (mode !== "image" || !image) return null;
15240
15590
  return {