feat: organize project conversations and add coding teacher side chat
This commit is contained in:
1 parent
2f82b9f79c
commit
9242a40d87
45 files changed
+2987
-1137
No files matched your search
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
@@ -42,13 +41,13 @@ import type {
|
||||
import { CodingComposer } from './CodingComposer';
|
||||
import { AgentBrowserPanel } from './AgentBrowserPanel';
|
||||
import { CodingChangesSummary } from './CodingChangesSummary';
|
||||
import { CodingConversationSidebar } from './CodingConversationSidebar';
|
||||
import { CodingConversationHeader } from './CodingConversationHeader';
|
||||
import { CodingConversationRenameDialog } from './CodingConversationRenameDialog';
|
||||
import { CodingConversationTimeline } from './CodingConversationTimeline';
|
||||
import { CodingInteractionPanel } from './CodingInteractionPanel';
|
||||
import { CodingWelcomeHero } from './CodingWelcomeHero';
|
||||
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
|
||||
import { TeacherChatPanel } from './TeacherChatPanel';
|
||||
import type { TeacherReference } from '../../../shared/coding-teacher';
|
||||
|
||||
export interface CodingChatPanelProps {
|
||||
navigationDraft?: string;
|
||||
@@ -85,10 +84,9 @@ function localSubmissionError(error: unknown): LocalSubmissionError {
|
||||
|
||||
function newestConversation(
|
||||
conversations: CodingConversationMetadata[],
|
||||
agentId: string,
|
||||
): CodingConversationMetadata | null {
|
||||
return conversations
|
||||
.filter((conversation) => conversation.agentId === agentId && !conversation.archivedAt)
|
||||
.filter((conversation) => !conversation.archivedAt)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -146,9 +144,6 @@ export function CodingChatPanel({
|
||||
);
|
||||
const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds);
|
||||
const loadWorkspace = useCodingWorkspaceStore((state) => state.load);
|
||||
const selectAgent = useCodingWorkspaceStore((state) => state.selectAgent);
|
||||
const ensureConversation = useCodingWorkspaceStore((state) => state.ensureConversation);
|
||||
const createConversation = useCodingWorkspaceStore((state) => state.createConversation);
|
||||
const patchConversation = useCodingWorkspaceStore((state) => state.patchConversation);
|
||||
const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation);
|
||||
|
||||
@@ -163,7 +158,6 @@ export function CodingChatPanel({
|
||||
const recoverConversation = useCodingConversationStore((state) => state.recoverConversation);
|
||||
const loadConversationSnapshot = useCodingConversationStore((state) => state.loadSnapshot);
|
||||
const markConversationUnread = useCodingConversationStore((state) => state.markUnread);
|
||||
const conversationSummaries = useCodingConversationStore((state) => state.summariesByConversationId);
|
||||
|
||||
const [provisionalDrafts, setProvisionalDrafts] = useState<Record<string, string>>({});
|
||||
const [submissionErrors, setSubmissionErrors] = useState<Record<string, LocalSubmissionError>>({});
|
||||
@@ -172,14 +166,10 @@ export function CodingChatPanel({
|
||||
Record<string, LocalComposerAttachment[]>
|
||||
>({});
|
||||
const [agentBrowserOpen, setAgentBrowserOpen] = useState(false);
|
||||
const [archivedContext, setArchivedContext] = useState<string | null>(null);
|
||||
const [renameTarget, setRenameTarget] = useState<{ projectId: string; conversation: CodingConversationMetadata } | null>(null);
|
||||
const [busyConversationIds, setBusyConversationIds] = useState<Record<string, boolean>>({});
|
||||
const archiveFlights = useRef(new Set<string>());
|
||||
const showArchived = Boolean(activeProject && selectedAgentId
|
||||
&& archivedContext === `${activeProject.id}:${selectedAgentId}`);
|
||||
const [teacherOpen, setTeacherOpen] = useState(false);
|
||||
const [teacherQuote, setTeacherQuote] = useState<TeacherReference | undefined>();
|
||||
const [teacherQuoteSource, setTeacherQuoteSource] = useState<string | null>(null);
|
||||
const appliedNavigationDraftRef = useRef<string | null>(null);
|
||||
const automaticCreationKeyRef = useRef<string | null>(null);
|
||||
const selectedConversationContextRef = useRef<string | null>(null);
|
||||
const attachmentsRef = useRef(attachmentsByDraftKey);
|
||||
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
|
||||
@@ -189,12 +179,10 @@ export function CodingChatPanel({
|
||||
const agents = useMemo(() => (
|
||||
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
|
||||
), [config]);
|
||||
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
|
||||
const selectedConversation = conversations.find((conversation) => (
|
||||
conversation.id === selectedConversationId
|
||||
&& conversation.agentId === selectedAgent?.id
|
||||
&& (!conversation.archivedAt || showArchived)
|
||||
)) ?? null;
|
||||
const selectedAgent = config?.agents.find((agent) => agent.id === (selectedConversation?.agentId ?? config.defaultAgentId ?? selectedAgentId)) ?? null;
|
||||
const targetConversationId = selectedConversation?.id ?? null;
|
||||
const conversationMetadataError = activeProject && targetConversationId
|
||||
? conversationErrorsByProjectId[activeProject.id]?.[targetConversationId] ?? null
|
||||
@@ -276,9 +264,8 @@ export function CodingChatPanel({
|
||||
|
||||
useEffect(() => {
|
||||
setAgentBrowserOpen(false);
|
||||
setArchivedContext(null);
|
||||
setRenameTarget(null);
|
||||
}, [activeProject?.id]);
|
||||
useEffect(() => { setTeacherQuote(undefined); }, [activeProject?.id, targetConversationId]);
|
||||
|
||||
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
|
||||
|
||||
@@ -319,7 +306,7 @@ export function CodingChatPanel({
|
||||
clearConversationSelection();
|
||||
return;
|
||||
}
|
||||
if (selectedConversation?.agentId === selectedAgent.id) {
|
||||
if (selectedConversation) {
|
||||
const selectionContext = `${activeProject.id}:${selectedConversation.id}`;
|
||||
if (selectedConversationContextRef.current !== selectionContext) {
|
||||
void selectProjectConversation(activeProject.id, selectedConversation.id)
|
||||
@@ -327,48 +314,24 @@ export function CodingChatPanel({
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (showArchived) {
|
||||
clearConversationSelection();
|
||||
return;
|
||||
}
|
||||
const next = newestConversation(conversations, selectedAgent.id);
|
||||
const next = newestConversation(conversations);
|
||||
if (next) {
|
||||
markConversationUnread(next.id, false);
|
||||
if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined);
|
||||
void selectProjectConversation(activeProject.id, next.id).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const creationKey = `${activeProject.id}:${selectedAgent.id}`;
|
||||
if (conversations.some((conversation) => conversation.agentId === selectedAgent.id)) {
|
||||
selectedConversationContextRef.current = null;
|
||||
clearConversationSelection();
|
||||
return;
|
||||
}
|
||||
if (automaticCreationKeyRef.current === creationKey) return;
|
||||
automaticCreationKeyRef.current = creationKey;
|
||||
void ensureConversation(selectedAgent.id)
|
||||
.then((conversation) => {
|
||||
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
|
||||
const current = codingWorkspaceStore.getState();
|
||||
if (current.activeProjectId === activeProject.id
|
||||
&& current.selectedAgentId === selectedAgent.id) {
|
||||
void selectProjectConversation(activeProject.id, conversation.id)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
clearConversationSelection();
|
||||
}, [
|
||||
activeProject,
|
||||
clearConversationSelection,
|
||||
conversations,
|
||||
ensureConversation,
|
||||
markConversationUnread,
|
||||
patchConversation,
|
||||
primeConversation,
|
||||
selectProjectConversation,
|
||||
selectedAgent,
|
||||
selectedConversation,
|
||||
showArchived,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -384,95 +347,6 @@ export function CodingChatPanel({
|
||||
}
|
||||
}, [navigationDraft, provisionalDraftKey, setConversationDraft, targetConversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetConversationId) return;
|
||||
if (provisionalDraft.trim()) {
|
||||
const current = codingConversationStore.getState().draftsByConversationId[targetConversationId];
|
||||
if (!current?.text) setConversationDraft(targetConversationId, provisionalDraft);
|
||||
if (provisionalDraftKey) {
|
||||
setProvisionalDrafts((drafts) => {
|
||||
const next = { ...drafts };
|
||||
delete next[provisionalDraftKey];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (provisionalDraftKey) {
|
||||
setAttachmentsByDraftKey((currentAttachments) => {
|
||||
const pending = currentAttachments[provisionalDraftKey];
|
||||
if (!pending?.length || currentAttachments[targetConversationId]?.length) return currentAttachments;
|
||||
const next = { ...currentAttachments, [targetConversationId]: pending };
|
||||
delete next[provisionalDraftKey];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [provisionalDraft, provisionalDraftKey, setConversationDraft, targetConversationId]);
|
||||
|
||||
const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => {
|
||||
if (!activeProject) return;
|
||||
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
|
||||
markConversationUnread(conversation.id, false);
|
||||
if (conversation.unread) {
|
||||
void patchConversation(conversation.id, { unread: false }).catch(() => undefined);
|
||||
}
|
||||
void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined);
|
||||
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectProjectConversation]);
|
||||
|
||||
const handleCreateConversation = useCallback(async () => {
|
||||
if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return;
|
||||
const projectId = activeProject.id;
|
||||
const agentId = selectedAgent.id;
|
||||
const conversation = await createConversation(agentId).catch(() => null);
|
||||
if (!conversation) return;
|
||||
primeConversation(createLocalConversationSnapshot(projectId, conversation));
|
||||
const current = codingWorkspaceStore.getState();
|
||||
if (current.activeProjectId === projectId && current.selectedAgentId === agentId) {
|
||||
setArchivedContext(null);
|
||||
await selectProjectConversation(projectId, conversation.id).catch(() => undefined);
|
||||
}
|
||||
}, [
|
||||
activeProject,
|
||||
createConversation,
|
||||
creatingAgentIds,
|
||||
primeConversation,
|
||||
selectProjectConversation,
|
||||
selectedAgent,
|
||||
]);
|
||||
|
||||
const handleArchiveConversation = async (conversation: CodingConversationMetadata, archived = !conversation.archivedAt) => {
|
||||
if (!activeProject || archiveFlights.current.has(conversation.id)) return;
|
||||
const projectId = activeProject.id;
|
||||
const agentId = conversation.agentId;
|
||||
const selectionAtStart = codingConversationStore.getState().selectedConversationId;
|
||||
archiveFlights.current.add(conversation.id);
|
||||
setBusyConversationIds((current) => ({ ...current, [conversation.id]: true }));
|
||||
try {
|
||||
const updated = await patchConversation(conversation.id, { archived });
|
||||
const current = codingWorkspaceStore.getState();
|
||||
if (current.activeProjectId !== projectId || current.selectedAgentId !== agentId) return;
|
||||
if (!archived && codingConversationStore.getState().selectedConversationId === selectionAtStart) {
|
||||
setArchivedContext(null);
|
||||
handleSelectConversation(updated);
|
||||
}
|
||||
if (archived) {
|
||||
const status = codingConversationStore.getState().summariesByConversationId[conversation.id]?.runStatus;
|
||||
const active = status && ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(status);
|
||||
toast.success(active ? '对话已归档,任务继续运行' : '对话已归档', {
|
||||
action: { label: '撤销', onClick: () => {
|
||||
if (codingWorkspaceStore.getState().activeProjectId === projectId) {
|
||||
void handleArchiveConversation(updated, false);
|
||||
}
|
||||
} },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '更新对话失败');
|
||||
} finally {
|
||||
archiveFlights.current.delete(conversation.id);
|
||||
setBusyConversationIds((current) => { const next = { ...current }; delete next[conversation.id]; return next; });
|
||||
}
|
||||
};
|
||||
|
||||
const handleForkConversation = useCallback(async (sourceEntryId?: string) => {
|
||||
if (!activeProject || !selectedAgent || !targetConversationId) return;
|
||||
const sourceProjectId = activeProject.id;
|
||||
@@ -591,6 +465,43 @@ export function CodingChatPanel({
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!targetConversationId && draftKey && activeProject && selectedAgent && !submissionFlightsRef.current.has(draftKey)) {
|
||||
const projectId = activeProject.id;
|
||||
const initialText = provisionalDraft;
|
||||
const attachments = [...localAttachments];
|
||||
const selectedAtSend = codingConversationStore.getState().selectedConversationId;
|
||||
let createdId: string | null = null;
|
||||
submissionFlightsRef.current.add(draftKey);
|
||||
void codingWorkspaceStore.getState().createConversation().then(async (conversation) => {
|
||||
createdId = conversation.id;
|
||||
primeConversation(createLocalConversationSnapshot(projectId, conversation));
|
||||
setConversationDraft(conversation.id, initialText);
|
||||
setProvisionalDrafts(current => {
|
||||
if (current[draftKey] !== initialText) return current;
|
||||
const next = { ...current }; delete next[draftKey]; return next;
|
||||
});
|
||||
setAttachmentsByDraftKey(current => {
|
||||
const next = { ...current, [conversation.id]: attachments };
|
||||
delete next[draftKey]; return next;
|
||||
});
|
||||
if (codingWorkspaceStore.getState().activeProjectId === projectId
|
||||
&& codingConversationStore.getState().selectedConversationId === selectedAtSend) {
|
||||
void selectProjectConversation(projectId, conversation.id).catch(() => undefined);
|
||||
}
|
||||
await submitPrompt({ conversationId: conversation.id, mode: 'prompt',
|
||||
prepareAttachments: attachments.length ? () => prepareAttachments(attachments) : undefined });
|
||||
setAttachmentsByDraftKey(current => {
|
||||
const next = { ...current }; delete next[conversation.id]; return next;
|
||||
});
|
||||
for (const attachment of attachments) {
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
uploadedAttachmentsRef.current.delete(attachment.id);
|
||||
}
|
||||
}).catch((error) => {
|
||||
setSubmissionErrors(current => ({ ...current, [createdId ?? draftKey]: localSubmissionError(error) }));
|
||||
}).finally(() => submissionFlightsRef.current.delete(draftKey));
|
||||
return;
|
||||
}
|
||||
if (!targetConversationId || !draftKey || submissionFlightsRef.current.has(targetConversationId)) return;
|
||||
const conversationId = targetConversationId;
|
||||
const attachments = [...localAttachments];
|
||||
@@ -625,6 +536,12 @@ export function CodingChatPanel({
|
||||
submissionFlightsRef.current.delete(conversationId);
|
||||
});
|
||||
}, [
|
||||
activeProject,
|
||||
selectedAgent,
|
||||
provisionalDraft,
|
||||
primeConversation,
|
||||
setConversationDraft,
|
||||
selectProjectConversation,
|
||||
draftKey,
|
||||
localAttachments,
|
||||
prepareAttachments,
|
||||
@@ -657,10 +574,9 @@ export function CodingChatPanel({
|
||||
interaction.status === 'pending'
|
||||
)).length ?? 0;
|
||||
const draft = selectedDraft?.text ?? provisionalDraft;
|
||||
const editable = Boolean(activeProject && selectedAgent);
|
||||
const editable = Boolean(activeProject && selectedAgent?.enabled && !selectedAgent.archivedAt);
|
||||
const canSend = Boolean(
|
||||
editable
|
||||
&& targetConversationId
|
||||
&& (draft.trim() || localAttachments.length > 0)
|
||||
&& modeMatchesRunState
|
||||
&& submittingRequestCount === 0
|
||||
@@ -751,35 +667,6 @@ export function CodingChatPanel({
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 overflow-hidden bg-background text-foreground" data-testid="coding-chat-panel">
|
||||
<CodingConversationSidebar
|
||||
projectName={activeProject?.name}
|
||||
agents={agents}
|
||||
conversations={conversations}
|
||||
conversationSummaries={conversationSummaries}
|
||||
selectedAgentId={selectedAgent?.id ?? null}
|
||||
selectedConversationId={targetConversationId}
|
||||
creatingAgentIds={creatingAgentIds}
|
||||
showArchived={showArchived}
|
||||
busyConversationIds={busyConversationIds}
|
||||
onToggleArchived={() => setArchivedContext(showArchived ? null : `${activeProject?.id}:${selectedAgentId}`)}
|
||||
onRenameConversation={(conversation) => { if (activeProject) setRenameTarget({ projectId: activeProject.id, conversation }); }}
|
||||
onArchiveConversation={(conversation) => void handleArchiveConversation(conversation)}
|
||||
onSelectAgent={(agentId) => {
|
||||
setArchivedContext(null);
|
||||
clearConversationSelection();
|
||||
selectAgent(agentId);
|
||||
}}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onCreateConversation={() => void handleCreateConversation()}
|
||||
onOpenProjectSettings={onOpenProjectSettings}
|
||||
/>
|
||||
{renameTarget && renameTarget.projectId === activeProject?.id && (
|
||||
<CodingConversationRenameDialog key={renameTarget.conversation.id}
|
||||
title={renameTarget.conversation.title}
|
||||
onSave={async (title) => { await patchConversation(renameTarget.conversation.id, { title }); }}
|
||||
onClose={() => setRenameTarget(null)} />
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CodingConversationHeader
|
||||
key={`header:${targetConversationId ?? 'none'}`}
|
||||
@@ -797,6 +684,12 @@ export function CodingChatPanel({
|
||||
browserOpen={agentBrowserOpen}
|
||||
browserAvailable={Boolean(activeProject)}
|
||||
onToggleBrowser={() => setAgentBrowserOpen((current) => !current)}
|
||||
onAskTeacher={() => {
|
||||
const selectedText = window.getSelection()?.toString().trim();
|
||||
setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined);
|
||||
setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`);
|
||||
setTeacherOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{(workspaceError || conversationMetadataError || connectionError) && (
|
||||
@@ -845,11 +738,11 @@ export function CodingChatPanel({
|
||||
</div>
|
||||
<h1 className="mt-4 text-balance text-lg font-semibold">项目已准备好</h1>
|
||||
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
需要开始对话时,再创建一个项目智能体并选择模型即可。
|
||||
请打开项目设置检查默认编程配置。
|
||||
</p>
|
||||
{onOpenProjectSettings ? (
|
||||
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
|
||||
创建项目智能体
|
||||
项目设置
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -857,9 +750,7 @@ export function CodingChatPanel({
|
||||
)
|
||||
: (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground" data-testid="coding-conversation-pending">
|
||||
{conversations.some((conversation) => conversation.agentId === selectedAgentId)
|
||||
? showArchived ? '选择一条已归档会话查看' : '没有最近会话,可新建对话或查看归档'
|
||||
: null}
|
||||
开始输入,创建项目的第一条会话
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -883,8 +774,11 @@ export function CodingChatPanel({
|
||||
{selectedConversation?.archivedAt ? (
|
||||
<div className="m-4 flex items-center justify-between gap-3 rounded-xl border bg-surface-subtle px-4 py-3 text-sm">
|
||||
<span>{running ? '此对话已归档,任务仍在运行。' : '此对话已归档,恢复后可继续聊天。'}</span>
|
||||
<Button disabled={busyConversationIds[selectedConversation.id]}
|
||||
onClick={() => void handleArchiveConversation(selectedConversation, false)}>恢复对话</Button>
|
||||
<Button onClick={() => void patchConversation(selectedConversation.id, { archived: false }).catch(() => undefined)}>恢复对话</Button>
|
||||
</div>
|
||||
) : selectedAgent && (!selectedAgent.enabled || selectedAgent.archivedAt) ? (
|
||||
<div className="m-4 rounded-xl border bg-surface-subtle px-4 py-3 text-sm">
|
||||
此历史会话的编程配置已停用,当前可查看历史与向老师提问。
|
||||
</div>
|
||||
) : <CodingComposer
|
||||
value={draft}
|
||||
@@ -900,7 +794,7 @@ export function CodingChatPanel({
|
||||
acceptedCount={acceptedRequestCount}
|
||||
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
|
||||
submitting={submittingRequestCount > 0}
|
||||
placeholder={selectedAgent ? '随心输入' : '请选择一个智能体后再发送'}
|
||||
placeholder={selectedAgent ? '随心输入' : '项目编程配置尚未就绪'}
|
||||
conversation={selectedConversation}
|
||||
snapshot={snapshot}
|
||||
voiceScopeKey={draftKey}
|
||||
@@ -946,6 +840,18 @@ export function CodingChatPanel({
|
||||
/>}
|
||||
</div>
|
||||
|
||||
{teacherOpen && targetConversationId && activeProject && (
|
||||
<div className="fixed inset-y-10 right-0 z-40 w-[min(400px,100vw)] shadow-xl xl:static xl:z-auto xl:w-[360px] xl:shrink-0 xl:shadow-none">
|
||||
<TeacherChatPanel key={`${activeProject.id}:${targetConversationId}`}
|
||||
projectId={activeProject.id} sourceId={targetConversationId}
|
||||
quote={teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined}
|
||||
onClose={() => setTeacherOpen(false)}
|
||||
onBringBack={selectedConversation?.archivedAt ? undefined : (text) => {
|
||||
const current = codingConversationStore.getState().draftsByConversationId[targetConversationId]?.text ?? '';
|
||||
setConversationDraft(targetConversationId, current ? current + '\n\n' + text : text);
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
<AgentBrowserPanel
|
||||
projectId={activeProject?.id ?? null}
|
||||
open={agentBrowserOpen}
|
||||
|
||||
Reference in new issue
Block a user