getobsrv 0.11.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.
@@ -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,7 +12699,8 @@ 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
  };
12702
12705
  const SCREEN_PRESETS = [
12703
12706
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -12761,6 +12764,27 @@ function profileToParams(p, hostNits) {
12761
12764
  dither: p.frc
12762
12765
  };
12763
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
+ }
12764
12788
  const CUSTOM_PRESET_ID = "custom";
12765
12789
  const FALLBACK_SCALE = 2;
12766
12790
  function sameError(a, b) {
@@ -12768,83 +12792,175 @@ function sameError(a, b) {
12768
12792
  if (a === null || b === null) return false;
12769
12793
  return a.code === b.code && a.url === b.url && a.description === b.description;
12770
12794
  }
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,
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,
12780
12842
  settings: { ...DEFAULT_SETTINGS },
12781
12843
  // Zeroes until the first `getHostInfo`; `selectScale` falls back meanwhile.
12782
12844
  host: { physicalWidth: 0, physicalHeight: 0, scaleFactor: 0 },
12783
- targetLoading: false,
12784
- error: null,
12785
12845
  toast: null,
12786
12846
  update: null,
12787
12847
  history: [],
12788
- image: null,
12789
12848
  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
12849
  panes: "both",
12797
- fitScale: null,
12798
- agentPan: null,
12799
- agentHighlight: null,
12800
12850
  // Does not clear `error`: a failed load navigates to Chromium's error page,
12801
12851
  // so clearing here would wipe the toolbar badge the moment it appeared.
12802
12852
  // Does clear the agent highlight: it marked pixels of the page that was
12803
12853
  // showing, and a committed navigation (a reload included) replaces them.
12804
- 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 }))),
12805
12860
  // A screen change re-rasters the target, so a highlight's target-pixel rect
12806
12861
  // 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 }),
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 })),
12810
12865
  // Picking a profile drops any hand-tuned slider values.
12811
- setProfile: (profileId) => set({ profileId, profileOverride: null }),
12812
- setProfileOverride: (profileOverride) => set({ profileOverride }),
12866
+ setProfile: (profileId) => set(patchActive({ profileId, profileOverride: null })),
12867
+ setProfileOverride: (profileOverride) => set(patchActive({ profileOverride })),
12813
12868
  setSettings: (settings) => set({ settings }),
12814
12869
  setHost: (host) => set({ host }),
12815
- setTargetLoading: (targetLoading) => set({ targetLoading }),
12870
+ setTargetLoading: (targetLoading) => set(patchActive({ targetLoading })),
12816
12871
  // Both panes report the same `loadError` for one failed navigation; the
12817
12872
  // duplicate must not replace the object and re-render everything twice.
12818
- setError: (error) => set((s) => sameError(s.error, error) ? {} : { error }),
12873
+ setError: (error) => set(patchActiveWith((t) => sameError(t.error, error) ? null : { error })),
12819
12874
  setUpdate: (update) => set({ update }),
12820
12875
  setHistory: (history) => set({ history }),
12821
12876
  setToast: (toast) => set({ toast }),
12822
- setImage: (image) => set({ image }),
12877
+ setImage: (image) => set(patchActive({ image })),
12823
12878
  setSurround: (surround) => set({ surround }),
12824
- setViewMode: (viewMode) => set({ viewMode }),
12879
+ setViewMode: (viewMode) => set(patchActive({ viewMode })),
12825
12880
  // No `agentHighlight: null` here, unlike setPreset: hiding a pane does not
12826
12881
  // re-raster the target, so the highlight still marks the pixels it marked.
12827
12882
  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 } : {}),
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)),
12833
12888
  // Spec §7: leaving image mode restores the URL that was showing before.
12834
12889
  // Either direction swaps what the target pane shows, so a highlight over
12835
12890
  // the old content is dropped with it.
12836
12891
  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
- )
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 } : {})
12839
12951
  }));
12952
+ function selectTab(s) {
12953
+ return s.tabs[s.activeId];
12954
+ }
12840
12955
  function selectScreen(s) {
12841
- 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);
12842
12958
  return preset ? {
12843
12959
  width: preset.width,
12844
12960
  height: preset.height,
12845
12961
  diagonalInches: preset.diagonalInches,
12846
12962
  deviceScaleFactor: preset.deviceScaleFactor
12847
- } : s.custom;
12963
+ } : tab.custom;
12848
12964
  }
12849
12965
  function selectDeviceScaleFactor(s) {
12850
12966
  return selectScreen(s).deviceScaleFactor ?? 1;
@@ -12864,7 +12980,7 @@ function calibratedScale(s) {
12864
12980
  diagonalInches: s.settings.hostDiagonalInches,
12865
12981
  scaleFactor: s.host.scaleFactor
12866
12982
  };
12867
- const scale = computeScale(host, screen, s.pixelExact);
12983
+ const scale = computeScale(host, screen, selectTab(s).pixelExact);
12868
12984
  return Number.isFinite(scale) && scale > 0 ? scale : null;
12869
12985
  }
12870
12986
  function selectScale(s) {
@@ -12877,13 +12993,15 @@ function selectHostNits(s) {
12877
12993
  return s.settings.hostNits > 0 ? s.settings.hostNits : DEFAULT_SETTINGS.hostNits;
12878
12994
  }
12879
12995
  function selectProfile(s) {
12880
- return s.profileOverride ?? findProfile(s.profileId);
12996
+ const tab = selectTab(s);
12997
+ return tab.profileOverride ?? findProfile(tab.profileId);
12881
12998
  }
12882
12999
  function selectPanelParams(s) {
12883
13000
  return profileToParams(selectProfile(s), selectHostNits(s));
12884
13001
  }
12885
13002
  function selectUrlBarText(s) {
12886
- return s.mode === "image" ? s.image?.name ?? "" : s.url;
13003
+ const tab = selectTab(s);
13004
+ return tab.mode === "image" ? tab.image?.name ?? "" : tab.url;
12887
13005
  }
12888
13006
  const SCALES = [1, 2, 3];
12889
13007
  const DEFAULT_SCALE = 2;
@@ -13060,10 +13178,10 @@ function TargetFooter() {
13060
13178
  const params = useStore(useShallow(selectPanelParams));
13061
13179
  const scale = useStore(selectScale);
13062
13180
  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);
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);
13067
13185
  const dsf = useStore(selectDeviceScaleFactor);
13068
13186
  const size = mode === "image" && image ? `${image.width}×${image.height}` : `${viewport.width}×${viewport.height}${dsf > 1 ? ` @${dsf}x` : ""}`;
13069
13187
  const depth = params.levels <= 63 ? "6-bit" : "8-bit";
@@ -13407,7 +13525,7 @@ function SettingsPanel() {
13407
13525
  const settings = useStore(useShallow((s) => s.settings));
13408
13526
  const update = useStore((s) => s.update);
13409
13527
  const history = useStore((s) => s.history);
13410
- const custom = useStore(useShallow((s) => s.custom));
13528
+ const custom = useStore(useShallow((s) => selectTab(s).custom));
13411
13529
  const viewport = useStore(useShallow(selectViewport));
13412
13530
  const scale = useStore(selectScale);
13413
13531
  const fallback = useStore(selectScaleIsFallback);
@@ -13564,6 +13682,24 @@ function SettingsPanel() {
13564
13682
  ] }),
13565
13683
  /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "check-now", onClick: () => void window.obsrv.checkUpdate(), children: "Check now" }),
13566
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." }),
13567
13703
  /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "History" }),
13568
13704
  /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "control inline record-history-toggle", children: [
13569
13705
  /* @__PURE__ */ jsxRuntimeExports.jsx(
@@ -14036,10 +14172,11 @@ function TargetCanvas({ onFatal, imageFrame }) {
14036
14172
  const params = useStore(useShallow(selectPanelParams));
14037
14173
  const dsf = useStore(selectDeviceScaleFactor);
14038
14174
  const requestedScale = useStore(selectScale);
14039
- const mode = useStore((s) => s.mode);
14040
- const viewMode = useStore((s) => s.viewMode);
14175
+ const mode = useStore((s) => selectTab(s).mode);
14176
+ const viewMode = useStore((s) => selectTab(s).viewMode);
14041
14177
  const setViewMode = useStore((s) => s.setViewMode);
14042
14178
  const setFitScale = useStore((s) => s.setFitScale);
14179
+ const activeId = useStore((s) => s.activeId);
14043
14180
  const [stalled, setStalled] = reactExports.useState(false);
14044
14181
  const stallTimer = reactExports.useRef(0);
14045
14182
  const armedOnce = reactExports.useRef(false);
@@ -14071,7 +14208,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14071
14208
  const smooth = fit || scale < 1;
14072
14209
  reactExports.useEffect(() => {
14073
14210
  setFitScale(fit ? scale : null);
14074
- }, [fit, scale, setFitScale]);
14211
+ }, [fit, scale, activeId, setFitScale]);
14075
14212
  reactExports.useEffect(() => () => setFitScale(null), [setFitScale]);
14076
14213
  const draw = reactExports.useRef({ scale, params, smooth, dsf });
14077
14214
  const imageRef = reactExports.useRef(imageFrame);
@@ -14110,7 +14247,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14110
14247
  offFrame = window.obsrv.onFrame((m) => {
14111
14248
  disarm();
14112
14249
  if (!gl) return;
14113
- if (useStore.getState().mode !== "url") return;
14250
+ if (selectTab(useStore.getState()).mode !== "url") return;
14114
14251
  gl.resizeSource(m.frameWidth, m.frameHeight);
14115
14252
  gl.uploadSlice(m.frame);
14116
14253
  schedule();
@@ -14138,7 +14275,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14138
14275
  const onWheel = (e) => {
14139
14276
  if (e.altKey) {
14140
14277
  e.preventDefault();
14141
- if (useStore.getState().viewMode !== "1:1") return;
14278
+ if (selectTab(useStore.getState()).viewMode !== "1:1") return;
14142
14279
  const body = canvas.closest(".pane-body");
14143
14280
  if (body instanceof HTMLElement) {
14144
14281
  body.scrollLeft += e.deltaX;
@@ -14146,7 +14283,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14146
14283
  }
14147
14284
  return;
14148
14285
  }
14149
- if (useStore.getState().mode !== "url") return;
14286
+ if (selectTab(useStore.getState()).mode !== "url") return;
14150
14287
  e.preventDefault();
14151
14288
  const r = canvas.getBoundingClientRect();
14152
14289
  const cssPerTarget = draw.current.scale * draw.current.dsf / (window.devicePixelRatio || 1);
@@ -14155,7 +14292,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14155
14292
  };
14156
14293
  canvas.addEventListener("wheel", onWheel, { passive: false });
14157
14294
  const onWindowUp = (e) => {
14158
- if (useStore.getState().mode !== "url") return;
14295
+ if (selectTab(useStore.getState()).mode !== "url") return;
14159
14296
  if (panRef.current || e.button === 1) return;
14160
14297
  if (e.altKey && !forwardDrag.current) return;
14161
14298
  if (e.target === canvas) return;
@@ -14178,8 +14315,10 @@ function TargetCanvas({ onFatal, imageFrame }) {
14178
14315
  };
14179
14316
  }, [onFatal, disarm]);
14180
14317
  reactExports.useEffect(
14181
- () => window.obsrv.onTargetNavigating(() => {
14182
- 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();
14183
14322
  }),
14184
14323
  [arm]
14185
14324
  );
@@ -14293,7 +14432,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14293
14432
  );
14294
14433
  setViewMode("1:1");
14295
14434
  };
14296
- const agentPan = useStore((s) => s.agentPan);
14435
+ const agentPan = useStore((s) => selectTab(s).agentPan);
14297
14436
  const clearAgentPan = useStore((s) => s.clearAgentPan);
14298
14437
  reactExports.useEffect(() => {
14299
14438
  if (!agentPan) return;
@@ -14310,7 +14449,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14310
14449
  body.scrollTop = jump.top;
14311
14450
  }
14312
14451
  }, [agentPan]);
14313
- const agentHighlight = useStore((s) => s.agentHighlight);
14452
+ const agentHighlight = useStore((s) => selectTab(s).agentHighlight);
14314
14453
  const clearAgentHighlight = useStore((s) => s.clearAgentHighlight);
14315
14454
  reactExports.useEffect(() => {
14316
14455
  if (!agentHighlight) return;
@@ -14415,6 +14554,23 @@ function matchHistory(entries, query, limit = HISTORY_SUGGESTIONS) {
14415
14554
  const needle = query.trim().toLowerCase();
14416
14555
  return entries.filter((e) => e.url.toLowerCase().includes(needle)).sort(byRank).slice(0, Math.max(0, limit));
14417
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
+ }
14418
14574
  /**
14419
14575
  * @license lucide-react v1.34.0 - ISC
14420
14576
  *
@@ -14542,42 +14698,53 @@ const createLucideIcon = (iconName, iconNode) => {
14542
14698
  * This source code is licensed under the ISC license.
14543
14699
  * See the LICENSE file in the root directory of this source tree.
14544
14700
  */
14545
- const __iconNode$7 = [
14701
+ const __iconNode$8 = [
14546
14702
  ["path", { d: "m12 19-7-7 7-7", key: "1l729n" }],
14547
14703
  ["path", { d: "M19 12H5", key: "x3x0zl" }]
14548
14704
  ];
14549
- const ArrowLeft = createLucideIcon("arrow-left", __iconNode$7);
14705
+ const ArrowLeft = createLucideIcon("arrow-left", __iconNode$8);
14550
14706
  /**
14551
14707
  * @license lucide-react v1.34.0 - ISC
14552
14708
  *
14553
14709
  * This source code is licensed under the ISC license.
14554
14710
  * See the LICENSE file in the root directory of this source tree.
14555
14711
  */
14556
- const __iconNode$6 = [
14712
+ const __iconNode$7 = [
14557
14713
  ["path", { d: "M5 12h14", key: "1ays0h" }],
14558
14714
  ["path", { d: "m12 5 7 7-7 7", key: "xquz4c" }]
14559
14715
  ];
14560
- const ArrowRight = createLucideIcon("arrow-right", __iconNode$6);
14716
+ const ArrowRight = createLucideIcon("arrow-right", __iconNode$7);
14561
14717
  /**
14562
14718
  * @license lucide-react v1.34.0 - ISC
14563
14719
  *
14564
14720
  * This source code is licensed under the ISC license.
14565
14721
  * See the LICENSE file in the root directory of this source tree.
14566
14722
  */
14567
- const __iconNode$5 = [["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }]];
14568
- 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);
14569
14725
  /**
14570
14726
  * @license lucide-react v1.34.0 - ISC
14571
14727
  *
14572
14728
  * This source code is licensed under the ISC license.
14573
14729
  * See the LICENSE file in the root directory of this source tree.
14574
14730
  */
14575
- const __iconNode$4 = [
14731
+ const __iconNode$5 = [
14576
14732
  ["circle", { cx: "12", cy: "12", r: "1", key: "41hilf" }],
14577
14733
  ["circle", { cx: "12", cy: "5", r: "1", key: "gxeob9" }],
14578
14734
  ["circle", { cx: "12", cy: "19", r: "1", key: "lyex9k" }]
14579
14735
  ];
14580
- 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);
14581
14748
  /**
14582
14749
  * @license lucide-react v1.34.0 - ISC
14583
14750
  *
@@ -14643,7 +14810,8 @@ const ICONS = {
14643
14810
  close: X,
14644
14811
  sliders: SlidersHorizontal,
14645
14812
  gear: Settings,
14646
- chevron: ChevronDown
14813
+ chevron: ChevronDown,
14814
+ plus: Plus
14647
14815
  };
14648
14816
  function Icon({ name, size = 16 }) {
14649
14817
  const Glyph = ICONS[name];
@@ -14714,6 +14882,77 @@ function Segmented({
14714
14882
  o.id
14715
14883
  )) });
14716
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
+ }
14717
14956
  function Select({ className, value, label, ariaLabel, onChange, children }) {
14718
14957
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "select-shell", children: [
14719
14958
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-label", "aria-hidden": "true", children: label }),
@@ -14744,12 +14983,12 @@ const PANES = [
14744
14983
  { id: "target", label: "Target", title: "The target render alone, full width" }
14745
14984
  ];
14746
14985
  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);
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);
14753
14992
  const barText = useStore(selectUrlBarText);
14754
14993
  const viewport = useStore(useShallow(selectViewport));
14755
14994
  const setUrl = useStore((s) => s.setUrl);
@@ -14761,7 +15000,7 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14761
15000
  const surround = useStore((s) => s.surround);
14762
15001
  const update = useStore((s) => s.update);
14763
15002
  const setSurround = useStore((s) => s.setSurround);
14764
- const viewMode = useStore((s) => s.viewMode);
15003
+ const viewMode = useStore((s) => selectTab(s).viewMode);
14765
15004
  const setViewMode = useStore((s) => s.setViewMode);
14766
15005
  const panes = useStore((s) => s.panes);
14767
15006
  const setPanes = useStore((s) => s.setPanes);
@@ -14779,19 +15018,7 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14779
15018
  setHighlight(-1);
14780
15019
  };
14781
15020
  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
- }, []);
15021
+ const agentActive = useAgentActivity();
14795
15022
  const toggleAgent = () => {
14796
15023
  const current = useStore.getState().settings;
14797
15024
  const next = { ...current, agentControl: !current.agentControl };
@@ -14858,6 +15085,7 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14858
15085
  const presetLabel = SCREEN_PRESETS.find((p) => p.id === presetId)?.label ?? "Custom";
14859
15086
  const profileLabel = PANEL_PROFILES.find((p) => p.id === profileId)?.label ?? profileId;
14860
15087
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome", children: [
15088
+ /* @__PURE__ */ jsxRuntimeExports.jsx(TabBar, {}),
14861
15089
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome-row chrome-browse", children: [
14862
15090
  /* @__PURE__ */ jsxRuntimeExports.jsx(
14863
15091
  "button",
@@ -15089,29 +15317,34 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
15089
15317
  function App() {
15090
15318
  const [fatal, setFatal] = reactExports.useState(null);
15091
15319
  const [drawer, setDrawer] = reactExports.useState("none");
15092
- const [image, setImage] = reactExports.useState(null);
15320
+ const [images, setImages] = reactExports.useState({});
15093
15321
  const dropToken = reactExports.useRef(0);
15094
15322
  const targetPaneRef = reactExports.useRef(null);
15095
15323
  const [targetBounds, setTargetBounds] = reactExports.useState(null);
15096
15324
  const [canvasBounds, setCanvasBounds] = reactExports.useState(null);
15325
+ const [tabsKnown, setTabsKnown] = reactExports.useState(false);
15097
15326
  const toggle = (which) => () => setDrawer((d) => d === which ? "none" : which);
15098
15327
  const setHost = useStore((s) => s.setHost);
15099
15328
  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);
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);
15103
15334
  const setUpdate = useStore((s) => s.setUpdate);
15104
15335
  const setHistory = useStore((s) => s.setHistory);
15105
15336
  const setImageMeta = useStore((s) => s.setImage);
15106
15337
  const setMode = useStore((s) => s.setMode);
15107
15338
  const setToast = useStore((s) => s.setToast);
15108
- const mode = useStore((s) => s.mode);
15339
+ const mode = useStore((s) => selectTab(s).mode);
15109
15340
  const surround = useStore((s) => s.surround);
15110
15341
  const viewport = useStore(useShallow(selectViewport));
15111
15342
  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);
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);
15115
15348
  const panes = useStore((s) => s.panes);
15116
15349
  const split = useStore((s) => s.settings.split);
15117
15350
  reactExports.useEffect(() => {
@@ -15119,28 +15352,36 @@ function App() {
15119
15352
  window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
15120
15353
  window.obsrv.getUpdate().then(setUpdate, (e) => console.warn("obsrv: getUpdate failed", e));
15121
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));
15122
15356
  const offs = [
15123
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
+ //
15124
15363
  // 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
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
15130
15369
  // nothing race it.
15131
- window.obsrv.onUrlChanged((url) => {
15132
- setError(null);
15133
- setUrl(url);
15370
+ window.obsrv.onUrlChanged(({ tabId, url }) => {
15371
+ setTabError(tabId, null);
15372
+ setTabUrl(tabId, url);
15134
15373
  }),
15135
- window.obsrv.onLoadError(setError),
15136
- window.obsrv.onTargetLoading(setTargetLoading),
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),
15137
15378
  window.obsrv.onUpdateStatus(setUpdate),
15138
15379
  window.obsrv.onHistoryChanged(setHistory)
15139
15380
  ];
15140
15381
  return () => {
15141
15382
  for (const off of offs) off();
15142
15383
  };
15143
- }, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate, setHistory]);
15384
+ }, [setHost, setSettings, setTabUrl, setTabTitle, setTabError, setTabLoading, syncTabs, setUpdate, setHistory]);
15144
15385
  reactExports.useEffect(() => {
15145
15386
  void window.obsrv.setViewport(viewport.width, viewport.height, deviceScaleFactor);
15146
15387
  }, [viewport.width, viewport.height, deviceScaleFactor]);
@@ -15180,8 +15421,9 @@ function App() {
15180
15421
  };
15181
15422
  }, []);
15182
15423
  reactExports.useEffect(() => {
15183
- window.obsrv.reportUiState({ presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds });
15184
- }, [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]);
15185
15427
  reactExports.useEffect(() => {
15186
15428
  return window.obsrv.onAgentApply((patch) => {
15187
15429
  const s = useStore.getState();
@@ -15197,21 +15439,24 @@ function App() {
15197
15439
  reactExports.useEffect(() => {
15198
15440
  document.documentElement.dataset.surround = surround;
15199
15441
  }, [surround]);
15442
+ const image = images[activeId] ?? null;
15200
15443
  const onImage = async (file, exportScale) => {
15201
15444
  const token = ++dropToken.current;
15445
+ const tabId = activeId;
15202
15446
  try {
15203
15447
  const limits = {
15204
15448
  ...DEFAULT_IMAGE_LIMITS,
15205
15449
  maxDimension: Math.min(DEFAULT_IMAGE_LIMITS.maxDimension, probeMaxTextureSize())
15206
15450
  };
15207
15451
  const loaded = await loadImage(file, exportScale, limits);
15208
- if (token !== dropToken.current) {
15452
+ if (token !== dropToken.current || useStore.getState().activeId !== tabId) {
15209
15453
  URL.revokeObjectURL(loaded.objectUrl);
15210
15454
  return;
15211
15455
  }
15212
- setImage((previous) => {
15213
- if (previous) URL.revokeObjectURL(previous.objectUrl);
15214
- return loaded;
15456
+ setImages((previous) => {
15457
+ const stale = previous[tabId];
15458
+ if (stale) URL.revokeObjectURL(stale.objectUrl);
15459
+ return { ...previous, [tabId]: loaded };
15215
15460
  });
15216
15461
  setImageMeta({
15217
15462
  name: file.name,
@@ -15230,11 +15475,28 @@ function App() {
15230
15475
  };
15231
15476
  reactExports.useEffect(() => {
15232
15477
  if (mode === "image") return;
15233
- setImage((previous) => {
15234
- if (previous) URL.revokeObjectURL(previous.objectUrl);
15235
- 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;
15236
15485
  });
15237
- }, [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]);
15238
15500
  const imageFrame = reactExports.useMemo(() => {
15239
15501
  if (mode !== "image" || !image) return null;
15240
15502
  return {