Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { useDisclosure, useLocalStorage, useWindowEvent } from "@mantine/hooks";
import {
Comment on lines +13 to 14
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Mantine useLocalStorage same tab synchronization multiple components

💡 Result:

Short answer: Mantine's useLocalStorage syncs across browser tabs/windows via the storage event, but it does NOT automatically propagate updates to other hook instances in the same tab. Use one of these patterns to keep multiple components in the same tab in sync:

  • Lift the state into a shared parent (or React context) and have components consume that single source of truth (recommended). [1]
  • Use a small global pub/sub (mitt or an EventTarget) so setValue writes localStorage and emits a same-tab event other hook instances listen to. [3]
  • Replace/use a custom "synced" hook that uses an emitter + storage events to sync both same-tab and cross-tab (examples exist, e.g. useSyncedLocalStorage). [3]

Background / why: Mantine's hook subscribes to the window "storage" event which only fires in other documents (other tabs/windows), not in the same tab where localStorage was changed — so same-tab updates won't trigger other hook instances. [1][2]

Sources:

  • Mantine useLocalStorage docs (browser tabs sync via storage event). [1]
  • Explanation that storage events don't fire in the same tab. [2]
  • Example approach / custom hook that adds same-tab sync via an emitter (useSyncedLocalStorage). [3]

Refine disable flag is not synchronized within the same tab—Refine button likely stays enabled during "Go deeper" response generation

The refine disable mechanism is split across three components using separate useLocalStorage instances:

  • RefineSelection writes true to localStorage via useLocalStorage when starting "Go deeper"
  • ParticipantConversationAudioContent clears it via removeValue() on onError/onFinish
  • ParticipantConversationAudio reads via its own useLocalStorage instance and disables the button via disabled={isStopping || isRefineDisabled}

The problem: Mantine's useLocalStorage only synchronizes state across browser tabs/windows via the storage event. Within the same tab, updating localStorage in one component does not automatically update the state in other components using the same key—each hook instance maintains isolated React state. When RefineSelection calls its setter, ParticipantConversationAudio's isRefineDisabled state does not update in the same tab. Additionally, navigating between nested routes keeps ParticipantConversationAudio mounted, so its initializer only runs once and won't pick up later writes.

Result: The Refine button is probably never actually disabled while a Go deeper response is generating, and may remain disabled longer than intended on cleanup. This defeats ECHO-614.

Recommendation: Replace the cross-component localStorage coordination with a single source of truth owned by the parent component and passed to children:

-const [isRefineDisabled, _setIsRefineDisabled] = useLocalStorage({
-  defaultValue: false,
-  key: `refine_disabled_${conversationId}`,
-});
+const [isRefineDisabled, setIsRefineDisabled] = useState(false);
...
<Outlet
  context={{
    isRecording,
+   isRefineDisabled,
+   setIsRefineDisabled,
  }}
/>

Then have RefineSelection and ParticipantConversationAudioContent call setIsRefineDisabled via useOutletContext() instead of their own useLocalStorage instances. You can optionally mirror to localStorage afterward if persistence across full page reloads is needed.

Applies to lines: 13–14, 50–56, 218–225, 458–474

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In echo/frontend/src/components/participant/ParticipantConversationAudio.tsx
around lines 13–14, 50–56, 218–225 and 458–474 the disable flag is implemented
with multiple useLocalStorage hooks which do not synchronize React state within
the same tab; replace this cross-component localStorage coordination with a
single boolean state (isRefineDisabled) declared in the parent component, pass
that state and its setter down to children via outlet context (useOutletContext)
or props, remove the child components' useLocalStorage usages so RefineSelection
calls setIsRefineDisabled(true) when starting "Go deeper" and
ParticipantConversationAudioContent calls setIsRefineDisabled(false) on
finish/error, and optionally mirror the parent state to localStorage only for
full-page reload persistence.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

IconCheck,
IconMicrophone,
Expand Down Expand Up @@ -42,7 +42,6 @@ import {
} from "./hooks";
import useChunkedAudioRecorder from "./hooks/useChunkedAudioRecorder";
import { PermissionErrorModal } from "./PermissionErrorModal";
import { useRefineSelectionCooldown } from "./refine/hooks/useRefineSelectionCooldown";
import { StopRecordingConfirmationModal } from "./StopRecordingConfirmationModal";

const CONVERSATION_DELETION_STATUS_CODES = [404, 403, 410];
Expand All @@ -51,6 +50,10 @@ const REFINE_BUTTON_THRESHOLD_SECONDS = 60;
export const ParticipantConversationAudio = () => {
const { projectId, conversationId } = useParams();
const location = useLocation();
const [isRefineDisabled, _setIsRefineDisabled] = useLocalStorage({
defaultValue: false,
key: `refine_disabled_${conversationId}`,
});
const textModeUrl = `/${projectId}/conversation/${conversationId}/text`;
const finishUrl = `/${projectId}/conversation/${conversationId}/finish`;

Expand Down Expand Up @@ -98,7 +101,6 @@ export const ParticipantConversationAudio = () => {
// Navigation and language
const navigate = useI18nNavigate();
const newConversationLink = useProjectSharingLink(projectQuery.data);
const cooldown = useRefineSelectionCooldown(conversationId);

const audioRecorder = useChunkedAudioRecorder({ deviceId, onChunk });
const wakeLock = useWakeLock({ obtainWakeLockOnMount: true });
Expand Down Expand Up @@ -219,17 +221,6 @@ export const ParticipantConversationAudio = () => {
return;
}

if (showVerify && !showEcho) {
navigate(`/${projectId}/conversation/${conversationId}/verify`);
return;
}

if (showEcho && !showVerify) {
cooldown.startEchoCooldown();
navigate(`/${projectId}/conversation/${conversationId}?echo=1`);
return;
}

navigate(`/${projectId}/conversation/${conversationId}/refine`);
};

Expand Down Expand Up @@ -298,21 +289,6 @@ export const ParticipantConversationAudio = () => {
);
};

const getRefineButtonText = () => {
if (showVerify && showEcho) {
return <Trans id="participant.button.refine">Refine</Trans>;
}
if (showVerify) {
return (
<Trans id="participant.button.make.concrete">Make it concrete</Trans>
);
}
if (showEcho) {
return <Trans id="participant.button.go.deeper">Go deeper</Trans>;
}
return <Trans id="participant.button.refine">Refine</Trans>;
};

const getRefineInfoReason = () => {
return (
<Trans id="participant.modal.refine.info.reason">
Expand Down Expand Up @@ -479,7 +455,7 @@ export const ParticipantConversationAudio = () => {
size="lg"
radius="md"
onClick={handleRefineClick}
disabled={isStopping}
disabled={isStopping || isRefineDisabled}
className="relative overflow-hidden"
variant={
recordingTime < REFINE_BUTTON_THRESHOLD_SECONDS
Expand All @@ -494,7 +470,7 @@ export const ParticipantConversationAudio = () => {
/>
)}
<span className="relative z-10">
{getRefineButtonText()}
<Trans id="participant.button.refine">Refine</Trans>
</span>
</Button>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useChat } from "@ai-sdk/react";
import { useLocalStorage } from "@mantine/hooks";
import { useEffect, useRef } from "react";
import { useOutletContext, useParams, useSearchParams } from "react-router";
import { API_BASE_URL } from "@/config";
Expand Down Expand Up @@ -26,6 +27,11 @@ export const ParticipantConversationAudioContent = () => {
const { isRecording } = useOutletContext<OutletContextType>();
const projectQuery = useParticipantProjectById(projectId ?? "");
const conversationQuery = useConversationQuery(projectId, conversationId);
const [_isRefineDisabled, _setIsRefineDisabled, removeValue] =
useLocalStorage({
defaultValue: false,
key: `refine_disabled_${conversationId}`,
});

const hasEchoParam = searchParams.get("echo") === "1";

Expand Down Expand Up @@ -53,6 +59,10 @@ export const ParticipantConversationAudioContent = () => {
})) ?? [],
onError: (error) => {
console.error("onError", error);
removeValue();
},
onFinish: () => {
removeValue();
},
});
const handleReply = async (e: React.MouseEvent<HTMLButtonElement>) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Trans } from "@lingui/react/macro";
import { Box, Group, Progress, Stack, Text, Title } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconArrowDownToArc, IconMessage } from "@tabler/icons-react";
import { useParams } from "react-router";
import { useParticipantProjectById } from "@/components/participant/hooks";
Expand All @@ -11,6 +12,10 @@ export const RefineSelection = () => {
const navigate = useI18nNavigate();
const cooldown = useRefineSelectionCooldown(conversationId);
const projectQuery = useParticipantProjectById(projectId ?? "");
const [_isRefineDisabled, setIsRefineDisabled] = useLocalStorage({
defaultValue: false,
key: `refine_disabled_${conversationId}`,
});

const handleVerifyClick = () => {
if (cooldown.verify.isActive) return;
Expand All @@ -20,6 +25,8 @@ export const RefineSelection = () => {
const handleEchoClick = () => {
if (cooldown.echo.isActive) return;
cooldown.startEchoCooldown();
// Disable refine button while echo is generating
setIsRefineDisabled(true);
navigate(`/${projectId}/conversation/${conversationId}?echo=1`);
};

Expand Down
6 changes: 4 additions & 2 deletions echo/frontend/src/components/project/ProjectPortalEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,9 @@ const ProjectPortalEditorComponent: React.FC<ProjectPortalEditorProps> = ({
</Title>
<Logo hideTitle />
<Badge>
<Trans>Beta</Trans>
<Trans id="dashboard.dembrane.concrete.beta">
Beta
</Trans>
</Badge>
</Group>

Expand Down Expand Up @@ -741,7 +743,7 @@ const ProjectPortalEditorComponent: React.FC<ProjectPortalEditorProps> = ({
</Title>
<Logo hideTitle />
<Badge>
<Trans id="dashboard.dembrane.concrete.experimental">
<Trans id="dashboard.dembrane.concrete.beta">
Beta
</Trans>
</Badge>
Expand Down
Loading
Loading