diff --git a/pages/_app.tsx b/pages/_app.tsx
index e810510fb..1fc891e48 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -11,6 +11,7 @@ import '~/common/styles/CodePrism.css';
import '~/common/styles/GithubMarkdown.css';
import { ProviderBackend } from '~/common/state/ProviderBackend';
+import { ProviderSingleTab } from '~/common/state/ProviderSingleTab';
import { ProviderSnacks } from '~/common/state/ProviderSnacks';
import { ProviderTRPCQueryClient } from '~/common/state/ProviderTRPCQueryClient';
import { ProviderTheming } from '~/common/state/ProviderTheming';
@@ -25,13 +26,15 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/common/app.routes.ts b/src/common/app.routes.ts
index e3281e602..a70994303 100644
--- a/src/common/app.routes.ts
+++ b/src/common/app.routes.ts
@@ -7,6 +7,7 @@
import Router from 'next/router';
import type { DConversationId } from '~/common/state/store-chats';
+import { isBrowser } from './util/pwaUtils';
export const ROUTE_INDEX = '/';
@@ -15,6 +16,7 @@ export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId';
export const ROUTE_APP_NEWS = '/news';
const ROUTE_CALLBACK_OPENROUTER = '/link/callback_openrouter';
+
// Get Paths
export const getCallbackUrl = (source: 'openrouter') => {
@@ -55,6 +57,8 @@ export const navigateToNews = navigateFn(ROUTE_APP_NEWS);
export const navigateBack = Router.back;
+export const reloadPage = () => isBrowser && window.location.reload();
+
function navigateFn(path: string) {
return (replace?: boolean): Promise => Router[replace ? 'replace' : 'push'](path);
}
diff --git a/src/common/components/useSingleTabEnforcer.ts b/src/common/components/useSingleTabEnforcer.ts
new file mode 100644
index 000000000..01ee765fe
--- /dev/null
+++ b/src/common/components/useSingleTabEnforcer.ts
@@ -0,0 +1,95 @@
+import * as React from 'react';
+
+/**
+ * The AloneDetector class checks if the current client is the only one present for a given app. It uses
+ * BroadcastChannel to talk to other clients. If no other clients reply within a short time, it assumes it's
+ * the only one and tells the caller.
+ */
+class AloneDetector {
+ private readonly clientId: string;
+ private readonly broadcastChannel: BroadcastChannel;
+
+ private aloneCallback: ((isAlone: boolean) => void) | null;
+ private aloneTimerId: number | undefined;
+
+ constructor(channelName: string, onAlone: (isAlone: boolean) => void) {
+
+ this.clientId = Math.random().toString(36).substring(2, 10);
+ this.aloneCallback = onAlone;
+
+ this.broadcastChannel = new BroadcastChannel(channelName);
+ this.broadcastChannel.onmessage = this.handleIncomingMessage;
+
+ }
+
+ public onUnmount(): void {
+ // close channel
+ this.broadcastChannel.onmessage = null;
+ this.broadcastChannel.close();
+
+ // clear timeout
+ if (this.aloneTimerId)
+ clearTimeout(this.aloneTimerId);
+
+ this.aloneTimerId = undefined;
+ this.aloneCallback = null;
+ }
+
+ public checkIfAlone(): void {
+
+ // triggers other clients
+ this.broadcastChannel.postMessage({ type: 'CHECK', sender: this.clientId });
+
+ // if no response within 500ms, assume this client is alone
+ this.aloneTimerId = window.setTimeout(() => {
+ this.aloneTimerId = undefined;
+ this.aloneCallback?.(true);
+ }, 500);
+
+ }
+
+ private handleIncomingMessage = (event: MessageEvent): void => {
+
+ // ignore self messages
+ if (event.data.sender === this.clientId) return;
+
+ switch (event.data.type) {
+
+ case 'CHECK':
+ this.broadcastChannel.postMessage({ type: 'ALIVE', sender: this.clientId });
+ break;
+
+ case 'ALIVE':
+ // received an ALIVE message, tell the client they're not alone
+ if (this.aloneTimerId) {
+ clearTimeout(this.aloneTimerId);
+ this.aloneTimerId = undefined;
+ }
+ this.aloneCallback?.(false);
+ this.aloneCallback = null;
+ break;
+
+ }
+ };
+}
+
+
+/**
+ * React hook that checks whether the current tab is the only one open for a specific channel.
+ *
+ * @param {string} channelName - The name of the BroadcastChannel to communicate on.
+ * @returns {boolean | null} - True if the current tab is alone, false if not, or null before the check completes.
+ */
+export function useSingleTabEnforcer(channelName: string): boolean | null {
+ const [isAlone, setIsAlone] = React.useState(null);
+
+ React.useEffect(() => {
+ const tabManager = new AloneDetector(channelName, setIsAlone);
+ tabManager.checkIfAlone();
+ return () => {
+ tabManager.onUnmount();
+ };
+ }, [channelName]);
+
+ return isAlone;
+}
\ No newline at end of file
diff --git a/src/common/state/ProviderSingleTab.tsx b/src/common/state/ProviderSingleTab.tsx
new file mode 100644
index 000000000..bded29888
--- /dev/null
+++ b/src/common/state/ProviderSingleTab.tsx
@@ -0,0 +1,42 @@
+import * as React from 'react';
+
+import { Button, Sheet, Typography } from '@mui/joy';
+
+import { Brand } from '../app.config';
+import { reloadPage } from '../app.routes';
+import { useSingleTabEnforcer } from '../components/useSingleTabEnforcer';
+
+
+export const ProviderSingleTab = (props: { children: React.ReactNode }) => {
+
+ // state
+ const isSingleTab = useSingleTabEnforcer('big-agi-tabs');
+
+ // pass-through until we know for sure that other tabs are open
+ if (isSingleTab === null || isSingleTab)
+ return props.children;
+
+
+ return (
+
+
+
+ It looks like {Brand.Title.Base} is already running in another tab or window.
+ To continue here, please close the other instance first.
+
+
+
+
+
+ );
+};
\ No newline at end of file