fix: bootstrap embedded chat prompting state and review read-only handling
Pass initial subagent prompting settings into embedded chat URLs and views Prevent inactive embedded chats from stealing focus on load Treat review sessions as independent conversations when resolving prompt read-only state
This commit is contained in:
+13
-2
@@ -106,6 +106,7 @@ type EmbeddedSessionChatConfig = {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
directory: string | null;
|
directory: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
allowPromptingSubagentSessions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EmbeddedVisibilityPayload = {
|
type EmbeddedVisibilityPayload = {
|
||||||
@@ -138,6 +139,9 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
directory,
|
directory,
|
||||||
readOnly: params.get('readOnly') === '1' || params.get('readOnly') === 'true',
|
readOnly: params.get('readOnly') === '1' || params.get('readOnly') === 'true',
|
||||||
|
allowPromptingSubagentSessions: params.has('allowPromptingSubagentSessions')
|
||||||
|
? params.get('allowPromptingSubagentSessions') === '1'
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,7 +203,11 @@ const EmbeddedSessionChatContent: React.FC<{
|
|||||||
<>
|
<>
|
||||||
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||||
<OpenCodeUpdateToast />
|
<OpenCodeUpdateToast />
|
||||||
<ChatView readOnly={embeddedSessionChat.readOnly} />
|
<ChatView
|
||||||
|
active={embeddedBackgroundWorkEnabled}
|
||||||
|
readOnly={embeddedSessionChat.readOnly}
|
||||||
|
initialAllowPromptingSubagentSessions={embeddedSessionChat.allowPromptingSubagentSessions}
|
||||||
|
/>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -228,7 +236,10 @@ function App({ apis }: AppProps) {
|
|||||||
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
||||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||||
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
|
// Embedded chats start inactive until the parent panel identifies the active
|
||||||
|
// tab. Otherwise a newly loaded background tab can focus its composer first
|
||||||
|
// and steal keyboard input from the main chat.
|
||||||
|
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(false);
|
||||||
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
|
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
|
||||||
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
|
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
|
||||||
const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0);
|
const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0);
|
||||||
|
|||||||
@@ -535,9 +535,10 @@ type ChatContainerProps = {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
autoOpenDraft?: boolean;
|
autoOpenDraft?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
initialAllowPromptingSubagentSessions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false }) => {
|
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false, initialAllowPromptingSubagentSessions }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Session UI state
|
// Session UI state
|
||||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
@@ -568,6 +569,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
|||||||
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
|
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
|
||||||
const promptNavigatorEnabled = useUIStore((state) => state.promptNavigatorEnabled);
|
const promptNavigatorEnabled = useUIStore((state) => state.promptNavigatorEnabled);
|
||||||
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
|
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
|
||||||
|
const [embeddedAllowPrompting, setEmbeddedAllowPrompting] = React.useState(initialAllowPromptingSubagentSessions);
|
||||||
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
|
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
|
||||||
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
|
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
|
||||||
|
|
||||||
@@ -800,7 +802,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
|||||||
{t('chat.container.returnToParent.label')}
|
{t('chat.container.returnToParent.label')}
|
||||||
</Button>
|
</Button>
|
||||||
) : null;
|
) : null;
|
||||||
const promptReadOnly = resolveChatPromptReadOnly(currentSession, allowPromptingSubagentSessions, readOnly);
|
const promptReadOnly = resolveChatPromptReadOnly(
|
||||||
|
currentSession,
|
||||||
|
embeddedAllowPrompting ?? allowPromptingSubagentSessions,
|
||||||
|
readOnly,
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// VS Code/Cursor/Positron webviews delete window.parent (and window.top).
|
// VS Code/Cursor/Positron webviews delete window.parent (and window.top).
|
||||||
@@ -813,6 +819,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
|||||||
|
|
||||||
const parentWindow = window.parent;
|
const parentWindow = window.parent;
|
||||||
const applySetting = (value: boolean) => {
|
const applySetting = (value: boolean) => {
|
||||||
|
setEmbeddedAllowPrompting(value);
|
||||||
useUIStore.getState().setAllowPromptingSubagentSessions(value);
|
useUIStore.getState().setAllowPromptingSubagentSessions(value);
|
||||||
};
|
};
|
||||||
const scopedWindow = window as typeof window & {
|
const scopedWindow = window as typeof window & {
|
||||||
@@ -1100,7 +1107,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
|||||||
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
|
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||||
</div>
|
</div>
|
||||||
{workStatusOverlayMountable ? (
|
{workStatusOverlayMountable ? (
|
||||||
<WorkStatusPanel
|
<WorkStatusPanel
|
||||||
@@ -1144,7 +1151,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 bg-background">
|
<div className="relative z-10 bg-background">
|
||||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1198,7 +1205,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
|||||||
: 'bg-background'
|
: 'bg-background'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1233,7 +1240,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
|||||||
: 'bg-background'
|
: 'bg-background'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1291,7 +1298,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
|
|||||||
onClick={navigation.resumeToLatest}
|
onClick={navigation.resumeToLatest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inside the chat column, not beside it: as a row sibling it took
|
{/* Inside the chat column, not beside it: as a row sibling it took
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ const MemoStatusRow = React.memo(StatusRow);
|
|||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
scrollToBottom?: () => void;
|
scrollToBottom?: () => void;
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
|
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
|
||||||
@@ -234,7 +235,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
|
|||||||
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
|
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Track if we restored a draft on mount (for text selection)
|
// Track if we restored a draft on mount (for text selection)
|
||||||
const initialDraftRef = React.useRef<string | null>(null);
|
const initialDraftRef = React.useRef<string | null>(null);
|
||||||
@@ -2039,10 +2040,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
||||||
if (currentSessionId && composerRef.current && !isMobile) {
|
if (active && currentSessionId && composerRef.current && !isMobile) {
|
||||||
composerRef.current.focus();
|
composerRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [currentSessionId, isMobile]);
|
}, [active, currentSessionId, isMobile]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||||
|
import { withReviewSessionMarker } from '@/lib/sessionReviewMetadata';
|
||||||
|
|
||||||
const session = (parentID?: string): Session => ({
|
const session = (parentID?: string): Session => ({
|
||||||
id: 'session',
|
id: 'session',
|
||||||
@@ -27,4 +28,14 @@ describe('resolveChatPromptReadOnly', () => {
|
|||||||
expect(resolveChatPromptReadOnly(session(), true, true)).toBe(true);
|
expect(resolveChatPromptReadOnly(session(), true, true)).toBe(true);
|
||||||
expect(resolveChatPromptReadOnly(session(), true, false)).toBe(false);
|
expect(resolveChatPromptReadOnly(session(), true, false)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('treats a marked code review as an independent session even with a stale parent ID', () => {
|
||||||
|
const reviewSession = {
|
||||||
|
...session('original'),
|
||||||
|
metadata: withReviewSessionMarker({}, 'original'),
|
||||||
|
} as Session;
|
||||||
|
|
||||||
|
expect(resolveChatPromptReadOnly(reviewSession, false, false)).toBe(false);
|
||||||
|
expect(resolveChatPromptReadOnly(reviewSession, true, true)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { isReviewSession } from '@/lib/sessionReviewMetadata';
|
||||||
|
|
||||||
export const resolveChatPromptReadOnly = (
|
export const resolveChatPromptReadOnly = (
|
||||||
session: Session | null | undefined,
|
session: Session | null | undefined,
|
||||||
allowPromptingSubagentSessions: boolean,
|
allowPromptingSubagentSessions: boolean,
|
||||||
readOnly: boolean,
|
readOnly: boolean,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
// Review sessions are independent conversations even if an older server or
|
||||||
|
// cached record still carries parentID. Their explicit metadata is the
|
||||||
|
// authority; only the surface itself may make them read-only.
|
||||||
|
if (isReviewSession(session)) {
|
||||||
|
return readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
if (session?.parentID) {
|
if (session?.parentID) {
|
||||||
return !allowPromptingSubagentSessions;
|
return !allowPromptingSubagentSessions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2528,8 +2528,8 @@ export const ContextPanel: React.FC = () => {
|
|||||||
lightThemeId,
|
lightThemeId,
|
||||||
darkThemeId,
|
darkThemeId,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
});
|
}, { allowPromptingSubagentSessions });
|
||||||
}, [currentTheme, darkThemeId, directoryKey, lightThemeId, themeMode]);
|
}, [allowPromptingSubagentSessions, currentTheme, darkThemeId, directoryKey, lightThemeId, themeMode]);
|
||||||
|
|
||||||
const activeChatSrc = activeChatTab && activeChatSessionID
|
const activeChatSrc = activeChatTab && activeChatSessionID
|
||||||
? getEmbeddedChatSrc(activeChatTab.id, activeChatSessionID, activeChatTab.readOnly)
|
? getEmbeddedChatSrc(activeChatTab.id, activeChatSessionID, activeChatTab.readOnly)
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ describe('embedded session chat URL', () => {
|
|||||||
expect(new URL(second).searchParams.get('themeVariant')).toBe('dark');
|
expect(new URL(second).searchParams.get('themeVariant')).toBe('dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bootstraps subagent prompting before the embedded chat first renders', () => {
|
||||||
|
const src = buildEmbeddedSessionChatURL('ses_1', '/repo', false, {
|
||||||
|
mode: 'system',
|
||||||
|
lightThemeId: 'light-a',
|
||||||
|
darkThemeId: 'dark-a',
|
||||||
|
currentTheme: makeTheme('dark-a', 'dark'),
|
||||||
|
}, { allowPromptingSubagentSessions: true });
|
||||||
|
|
||||||
|
expect(new URL(src).searchParams.get('allowPromptingSubagentSessions')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
test('rebuilds cached src when readOnly changes for an existing tab', () => {
|
test('rebuilds cached src when readOnly changes for an existing tab', () => {
|
||||||
const cache = new Map<string, EmbeddedSessionChatURLCacheEntry>();
|
const cache = new Map<string, EmbeddedSessionChatURLCacheEntry>();
|
||||||
const theme = {
|
const theme = {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export type EmbeddedSessionChatThemeBootstrap = {
|
|||||||
currentTheme: Theme;
|
currentTheme: Theme;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EmbeddedSessionChatSettingsBootstrap = {
|
||||||
|
allowPromptingSubagentSessions: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type EmbeddedSessionChatURLCacheEntry = {
|
export type EmbeddedSessionChatURLCacheEntry = {
|
||||||
signature: string;
|
signature: string;
|
||||||
src: string;
|
src: string;
|
||||||
@@ -111,6 +115,7 @@ export const buildEmbeddedSessionChatURL = (
|
|||||||
directory: string | null,
|
directory: string | null,
|
||||||
readOnly: boolean,
|
readOnly: boolean,
|
||||||
theme: EmbeddedSessionChatThemeBootstrap,
|
theme: EmbeddedSessionChatThemeBootstrap,
|
||||||
|
settings?: EmbeddedSessionChatSettingsBootstrap,
|
||||||
): string => {
|
): string => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return '';
|
return '';
|
||||||
@@ -134,6 +139,9 @@ export const buildEmbeddedSessionChatURL = (
|
|||||||
url.searchParams.set('lightThemeId', theme.lightThemeId);
|
url.searchParams.set('lightThemeId', theme.lightThemeId);
|
||||||
url.searchParams.set('darkThemeId', theme.darkThemeId);
|
url.searchParams.set('darkThemeId', theme.darkThemeId);
|
||||||
url.searchParams.set('themeVariant', theme.currentTheme.metadata.variant === 'dark' ? 'dark' : 'light');
|
url.searchParams.set('themeVariant', theme.currentTheme.metadata.variant === 'dark' ? 'dark' : 'light');
|
||||||
|
if (settings) {
|
||||||
|
url.searchParams.set('allowPromptingSubagentSessions', settings.allowPromptingSubagentSessions ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
url.hash = '';
|
url.hash = '';
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -146,6 +154,7 @@ export const getOrCreateEmbeddedSessionChatURL = (
|
|||||||
directory: string | null,
|
directory: string | null,
|
||||||
readOnly: boolean,
|
readOnly: boolean,
|
||||||
theme: EmbeddedSessionChatThemeBootstrap,
|
theme: EmbeddedSessionChatThemeBootstrap,
|
||||||
|
settings?: EmbeddedSessionChatSettingsBootstrap,
|
||||||
): string => {
|
): string => {
|
||||||
const signature = buildEmbeddedSessionChatURLSignature(sessionID, directory, readOnly);
|
const signature = buildEmbeddedSessionChatURLSignature(sessionID, directory, readOnly);
|
||||||
const existing = cache.get(tabID);
|
const existing = cache.get(tabID);
|
||||||
@@ -153,7 +162,7 @@ export const getOrCreateEmbeddedSessionChatURL = (
|
|||||||
return existing.src;
|
return existing.src;
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme);
|
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme, settings);
|
||||||
cache.set(tabID, { signature, src });
|
cache.set(tabID, { signature, src });
|
||||||
return src;
|
return src;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
|||||||
type ChatViewProps = {
|
type ChatViewProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
initialAllowPromptingSubagentSessions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatView: React.FC<ChatViewProps> = ({ active = true, readOnly = false }) => {
|
export const ChatView: React.FC<ChatViewProps> = ({ active = true, readOnly = false, initialAllowPromptingSubagentSessions }) => {
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
|
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
|
||||||
<ChatContainer active={active} readOnly={readOnly} />
|
<ChatContainer
|
||||||
|
active={active}
|
||||||
|
readOnly={readOnly}
|
||||||
|
initialAllowPromptingSubagentSessions={initialAllowPromptingSubagentSessions}
|
||||||
|
/>
|
||||||
</ChatErrorBoundary>
|
</ChatErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user