Add AI Elements-inspired chat primitives
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
id: TASK-10
|
||||||
|
title: Modernize savings chat agent UI with AI Elements phases
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-06-16 08:38'
|
||||||
|
updated_date: '2026-06-16 08:41'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
ordinal: 10000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Implement the planned AI Elements-inspired savings chat agent UI in three sequential phases: conversation/message/prompt/tool trace primitives, reasoning/work-progress disclosure, and sources/citation support.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Phase 1 replaces the basic chat surface with reusable conversation, message, prompt input, and tool trace UI primitives
|
||||||
|
- [ ] #2 Phase 2 adds a safe reasoning/work-progress disclosure derived from existing tool traces, without exposing hidden chain-of-thought
|
||||||
|
- [ ] #3 Phase 3 adds structured source/citation support through stored assistant metadata and visible UI affordances
|
||||||
|
- [ ] #4 Each phase is covered by failing-first tests, verified after implementation, and committed separately
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Phase 1 TDD: add tested chat UI view-model helpers, reusable conversation/message/prompt/tool trace components, and integrate them into SavingsChatPage.
|
||||||
|
2. Commit Phase 1.
|
||||||
|
3. Phase 2 TDD: derive safe reasoning/work-progress summaries from tool traces and render them as collapsible disclosure.
|
||||||
|
4. Commit Phase 2.
|
||||||
|
5. Phase 3 TDD: extend chat message metadata with structured sources/citations, persist it through Convex history, and render sources/inline citations.
|
||||||
|
6. Commit Phase 3.
|
||||||
|
7. Run focused tests, lint, build, and record verification notes without closing the task until user confirmation.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Phase 1 complete: added AgentChat primitives for conversation, message rendering, prompt input, and tool trace disclosure; integrated SavingsChatPage. Verification: npx vitest src/components/chat/AgentChat.test.tsx --run, npx eslint targeted chat/page files, npm run build (Vite chunk-size warning only).
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
113
src/components/chat/AgentChat.test.tsx
Normal file
113
src/components/chat/AgentChat.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
AgentConversation,
|
||||||
|
AgentMessage,
|
||||||
|
AgentPromptInput,
|
||||||
|
type AgentChatMessage,
|
||||||
|
} from "./AgentChat";
|
||||||
|
import { getToolTraceSummary } from "./agentChatModel";
|
||||||
|
|
||||||
|
const assistantMessage: AgentChatMessage = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Deine groesste Ausgabe war Miete.",
|
||||||
|
toolTrace: [
|
||||||
|
{
|
||||||
|
name: "summarize_transactions",
|
||||||
|
inputSummary: "Mai 2026",
|
||||||
|
resultSummary: "12 Umsaetze zusammengefasst",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_transactions",
|
||||||
|
inputSummary: "Miete",
|
||||||
|
resultSummary: "1 Treffer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AgentChat phase 1 components", () => {
|
||||||
|
test("summarizes tool traces for compact agent transparency", () => {
|
||||||
|
expect(getToolTraceSummary(undefined)).toBe("Keine Werkzeuge");
|
||||||
|
expect(getToolTraceSummary([])).toBe("Keine Werkzeuge");
|
||||||
|
expect(getToolTraceSummary(assistantMessage.toolTrace)).toBe("2 Werkzeuge verwendet");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders conversation messages with assistant tool trace details", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AgentConversation
|
||||||
|
messages={[
|
||||||
|
{ id: "user-1", role: "user", content: "Was war teuer?" },
|
||||||
|
assistantMessage,
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Was war teuer?");
|
||||||
|
expect(markup).toContain("Deine groesste Ausgabe war Miete.");
|
||||||
|
expect(markup).toContain("2 Werkzeuge verwendet");
|
||||||
|
expect(markup).toContain("summarize_transactions");
|
||||||
|
expect(markup).toContain("12 Umsaetze zusammengefasst");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders the prompt input with ready and busy states", () => {
|
||||||
|
const readyMarkup = renderToStaticMarkup(
|
||||||
|
<AgentPromptInput
|
||||||
|
value="Bitte analysieren"
|
||||||
|
disabled={false}
|
||||||
|
isSubmitting={false}
|
||||||
|
placeholder="Welche Auswertung soll ich machen?"
|
||||||
|
onChange={() => undefined}
|
||||||
|
onSubmit={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const busyMarkup = renderToStaticMarkup(
|
||||||
|
<AgentPromptInput
|
||||||
|
value="Bitte analysieren"
|
||||||
|
disabled
|
||||||
|
isSubmitting
|
||||||
|
placeholder="Chat wird vorbereitet..."
|
||||||
|
onChange={() => undefined}
|
||||||
|
onSubmit={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readyMarkup).toContain("Welche Auswertung soll ich machen?");
|
||||||
|
expect(readyMarkup).toContain("Senden");
|
||||||
|
expect(busyMarkup).toContain("Antwort laeuft");
|
||||||
|
expect(busyMarkup).toContain("disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disables prompt submit for whitespace-only input", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AgentPromptInput
|
||||||
|
value=" "
|
||||||
|
disabled={false}
|
||||||
|
isSubmitting={false}
|
||||||
|
placeholder="Welche Auswertung soll ich machen?"
|
||||||
|
onChange={() => undefined}
|
||||||
|
onSubmit={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a thinking indicator while the agent is submitting", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AgentConversation messages={[assistantMessage]} isSubmitting />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Denk mit der KI nach...");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a single message without a tool section when no trace exists", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AgentMessage message={{ id: "assistant-empty", role: "assistant", content: "Hallo" }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Assistant");
|
||||||
|
expect(markup).toContain("Hallo");
|
||||||
|
expect(markup).not.toContain("Werkzeuge verwendet");
|
||||||
|
});
|
||||||
|
});
|
||||||
162
src/components/chat/AgentChat.tsx
Normal file
162
src/components/chat/AgentChat.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
type FormEvent,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
type Ref,
|
||||||
|
} from "react";
|
||||||
|
import { Loader2, Send, Wrench } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
getToolTraceSummary,
|
||||||
|
type AgentChatMessage,
|
||||||
|
type AgentToolTrace,
|
||||||
|
} from "./agentChatModel";
|
||||||
|
export type { AgentChatMessage, AgentToolTrace } from "./agentChatModel";
|
||||||
|
|
||||||
|
type AgentConversationProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
messages: AgentChatMessage[];
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
scrollRef?: Ref<HTMLDivElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentMessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
message: AgentChatMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentPromptInputProps = {
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
disabled: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentConversation({
|
||||||
|
messages,
|
||||||
|
isSubmitting = false,
|
||||||
|
scrollRef,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AgentConversationProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Chatverlauf"
|
||||||
|
className={cn("h-[52vh] overflow-y-auto rounded-md border bg-background", className)}
|
||||||
|
ref={scrollRef}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<AgentMessage key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
{isSubmitting && <AgentThinkingIndicator />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentMessage({ message, className, ...props }: AgentMessageProps) {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={cn(
|
||||||
|
"flex w-full",
|
||||||
|
isUser ? "justify-end" : "justify-start",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[min(100%,46rem)] rounded-lg border px-3 py-2 text-sm",
|
||||||
|
isUser ? "bg-muted/60" : "bg-card text-card-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-[0.7rem] font-medium uppercase text-muted-foreground">
|
||||||
|
{isUser ? "User" : "Assistant"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap leading-6">{message.content}</p>
|
||||||
|
{!isUser && message.toolTrace && message.toolTrace.length > 0 && (
|
||||||
|
<AgentToolTracePanel toolTrace={message.toolTrace} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentToolTracePanel({ toolTrace }: { toolTrace: AgentToolTrace[] }) {
|
||||||
|
return (
|
||||||
|
<details className="mt-3 rounded-md border bg-muted/30 px-2 py-1.5">
|
||||||
|
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<Wrench className="h-3.5 w-3.5" />
|
||||||
|
{getToolTraceSummary(toolTrace)}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{toolTrace.map((tool, toolIndex) => (
|
||||||
|
<div key={`${tool.name}-${toolIndex}`} className="rounded-md bg-background/80 p-2 text-xs">
|
||||||
|
<p className="font-medium text-foreground">{tool.name}</p>
|
||||||
|
<ToolTraceLine label="Eingabe" value={tool.inputSummary} />
|
||||||
|
<ToolTraceLine label="Ergebnis" value={tool.resultSummary} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolTraceLine({ label, value }: { label: ReactNode; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{label}: </span>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentThinkingIndicator() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-1 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Denk mit der KI nach...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPromptInput({
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
isSubmitting,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: AgentPromptInputProps) {
|
||||||
|
const submitDisabled = disabled || value.trim().length === 0;
|
||||||
|
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="rounded-lg border bg-card p-2 shadow-sm"
|
||||||
|
aria-label="Chatnachricht senden"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
className="min-h-11 flex-1 resize-none bg-transparent px-2 py-2 text-sm leading-6 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={submitDisabled} className="shrink-0">
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
{isSubmitting ? "Antwort laeuft" : "Senden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/chat/agentChatModel.ts
Normal file
17
src/components/chat/agentChatModel.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type AgentToolTrace = {
|
||||||
|
name: string;
|
||||||
|
inputSummary: string;
|
||||||
|
resultSummary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
toolTrace?: AgentToolTrace[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getToolTraceSummary(toolTrace: AgentToolTrace[] | undefined) {
|
||||||
|
if (!toolTrace || toolTrace.length === 0) return "Keine Werkzeuge";
|
||||||
|
return `${toolTrace.length} ${toolTrace.length === 1 ? "Werkzeug" : "Werkzeuge"} verwendet`;
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||||||
import { MessageCircle, Send, Loader2 } from "lucide-react";
|
import { MessageCircle } from "lucide-react";
|
||||||
import { api } from "../../convex/_generated/api";
|
import { api } from "../../convex/_generated/api";
|
||||||
import type { Id } from "../../convex/_generated/dataModel";
|
import type { Id } from "../../convex/_generated/dataModel";
|
||||||
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||||||
import { useFilters } from "@/context/FilterContext";
|
import { useFilters } from "@/context/FilterContext";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
AgentConversation,
|
||||||
|
AgentPromptInput,
|
||||||
|
type AgentChatMessage,
|
||||||
|
} from "@/components/chat/AgentChat";
|
||||||
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
|
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -163,6 +166,18 @@ export function SavingsChatPage() {
|
|||||||
[messagesQuery.results],
|
[messagesQuery.results],
|
||||||
);
|
);
|
||||||
const displayMessages = activeSessionId && messages.length > 0 ? messages : fallbackMessages;
|
const displayMessages = activeSessionId && messages.length > 0 ? messages : fallbackMessages;
|
||||||
|
const agentMessages: AgentChatMessage[] = useMemo(
|
||||||
|
() =>
|
||||||
|
displayMessages.map((message) => ({
|
||||||
|
id: message._id,
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
...(message.role === "assistant" && message.toolTrace
|
||||||
|
? { toolTrace: message.toolTrace }
|
||||||
|
: {}),
|
||||||
|
})),
|
||||||
|
[displayMessages],
|
||||||
|
);
|
||||||
|
|
||||||
const context = useQuery(api.savingsChat.getContext, {
|
const context = useQuery(api.savingsChat.getContext, {
|
||||||
from,
|
from,
|
||||||
@@ -186,8 +201,6 @@ export function SavingsChatPage() {
|
|||||||
? legacyImportResult.importedCount
|
? legacyImportResult.importedCount
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const buttonDisabled = isSubmitting || draft.trim().length === 0 || !activeSessionId;
|
|
||||||
|
|
||||||
const formatAmount = (amount: number) =>
|
const formatAmount = (amount: number) =>
|
||||||
new Intl.NumberFormat("de-DE", {
|
new Intl.NumberFormat("de-DE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -353,60 +366,20 @@ export function SavingsChatPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<AgentConversation
|
||||||
<CardContent className="p-4">
|
messages={agentMessages}
|
||||||
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
|
isSubmitting={isSubmitting}
|
||||||
<div className="space-y-3">
|
scrollRef={listRef}
|
||||||
{displayMessages.map((message) => (
|
/>
|
||||||
<div
|
|
||||||
key={message._id}
|
<AgentPromptInput
|
||||||
className={`rounded-lg border p-3 ${
|
value={draft}
|
||||||
message.role === "user" ? "bg-muted/50" : "bg-background"
|
onChange={setDraft}
|
||||||
}`}
|
onSubmit={submit}
|
||||||
>
|
placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet..."}
|
||||||
<p className="text-xs uppercase text-muted-foreground">{message.role}</p>
|
disabled={isSubmitting || !activeSessionId}
|
||||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
isSubmitting={isSubmitting}
|
||||||
{message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && (
|
|
||||||
<div className="mt-3 rounded-md border bg-muted/30 p-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
|
||||||
Verwendete Werkzeuge
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{message.toolTrace.map((tool, toolIndex) => (
|
|
||||||
<div key={`${tool.name}-${toolIndex}`} className="text-xs">
|
|
||||||
<p className="font-medium">{tool.name}</p>
|
|
||||||
<p className="text-muted-foreground">{tool.resultSummary}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{isSubmitting && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Denk mit der KI nach…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<form className="flex gap-2" onSubmit={submit}>
|
|
||||||
<Input
|
|
||||||
value={draft}
|
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
|
||||||
placeholder={activeSession ? "Welche Auswertung soll ich machen?" : "Chat wird vorbereitet…"}
|
|
||||||
disabled={isSubmitting || !activeSessionId}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={buttonDisabled}>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
Senden
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
Reference in New Issue
Block a user