diff --git a/app/src/agentworld/components/ConfirmDialog.test.tsx b/app/src/agentworld/components/ConfirmDialog.test.tsx
new file mode 100644
index 0000000000..146fd77fd8
--- /dev/null
+++ b/app/src/agentworld/components/ConfirmDialog.test.tsx
@@ -0,0 +1,57 @@
+/**
+ * Tests for ConfirmDialog — the in-app confirmation modal that replaces native
+ * window.confirm for Agent World destructive actions (#4197). Covers rendering
+ * of title/message, confirm/cancel callbacks, and the busy state.
+ */
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, test, vi } from 'vitest';
+
+import ConfirmDialog from './ConfirmDialog';
+
+function baseProps() {
+ return {
+ title: 'Delete post',
+ message: "Delete this post? This can't be undone.",
+ onConfirm: vi.fn(),
+ onCancel: vi.fn(),
+ };
+}
+
+describe('ConfirmDialog', () => {
+ test('renders the title and message', () => {
+ render();
+ expect(screen.getByText('Delete post')).toBeInTheDocument();
+ expect(screen.getByTestId('confirm-dialog-message')).toHaveTextContent(
+ "Delete this post? This can't be undone."
+ );
+ });
+
+ test('calls onConfirm when the confirm button is clicked', async () => {
+ const props = baseProps();
+ render();
+ await userEvent.click(screen.getByTestId('confirm-dialog-confirm'));
+ expect(props.onConfirm).toHaveBeenCalledTimes(1);
+ expect(props.onCancel).not.toHaveBeenCalled();
+ });
+
+ test('calls onCancel when the cancel button is clicked', async () => {
+ const props = baseProps();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(props.onCancel).toHaveBeenCalledTimes(1);
+ expect(props.onConfirm).not.toHaveBeenCalled();
+ });
+
+ test('disables the confirm button and shows busyLabel while busy', () => {
+ render();
+ const confirm = screen.getByTestId('confirm-dialog-confirm');
+ expect(confirm).toBeDisabled();
+ expect(confirm).toHaveTextContent('Deleting…');
+ });
+
+ test('uses a custom confirm label when provided', () => {
+ render();
+ expect(screen.getByTestId('confirm-dialog-confirm')).toHaveTextContent('Remove');
+ });
+});
diff --git a/app/src/agentworld/components/ConfirmDialog.tsx b/app/src/agentworld/components/ConfirmDialog.tsx
new file mode 100644
index 0000000000..c38c828db0
--- /dev/null
+++ b/app/src/agentworld/components/ConfirmDialog.tsx
@@ -0,0 +1,74 @@
+/**
+ * ConfirmDialog — in-app confirmation modal for Agent World destructive actions.
+ *
+ * Replaces native `window.confirm()` (which renders the OS/browser dialog
+ * titled "tauri.localhost says …", exposing the internal hostname to users —
+ * #4197) with a styled modal consistent with the app's design system, built on
+ * the shared [`ModalShell`].
+ *
+ * The parent owns the action: this component only renders the confirmation and
+ * reports the user's decision via `onConfirm` / `onCancel`. Render it
+ * conditionally from parent state and await the user's choice before firing the
+ * destructive RPC.
+ */
+import Button from '../../components/ui/Button';
+import { ModalShell } from '../../components/ui/ModalShell';
+
+export interface ConfirmDialogProps {
+ /** Modal header (e.g. "Delete post"). */
+ title: string;
+ /** Body copy explaining the consequence (e.g. "Delete this post? This can't be undone."). */
+ message: string;
+ /** Confirm-button label. Defaults to "Delete". */
+ confirmLabel?: string;
+ /** Cancel-button label. Defaults to "Cancel". */
+ cancelLabel?: string;
+ /** Render the confirm button with the danger tone (default true — these are destructive). */
+ destructive?: boolean;
+ /** When true, the confirm button is disabled and shows `busyLabel`. */
+ busy?: boolean;
+ /** Label shown on the confirm button while `busy` (e.g. "Deleting…"). */
+ busyLabel?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export default function ConfirmDialog({
+ title,
+ message,
+ confirmLabel = 'Delete',
+ cancelLabel = 'Cancel',
+ destructive = true,
+ busy = false,
+ busyLabel = 'Deleting…',
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ return (
+ undefined : onCancel}
+ maxWidthClassName="max-w-sm">
+
+
+ {message}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/agentworld/pages/FeedSection.test.tsx b/app/src/agentworld/pages/FeedSection.test.tsx
index 1a0b782503..c07027c6fb 100644
--- a/app/src/agentworld/pages/FeedSection.test.tsx
+++ b/app/src/agentworld/pages/FeedSection.test.tsx
@@ -674,7 +674,6 @@ describe('delete actions', () => {
test('clicking delete calls feeds.deletePost then refetches feed', async () => {
const user = userEvent.setup();
- vi.spyOn(window, 'confirm').mockReturnValue(true);
const ownPost = { ...samplePost, author: { ...sampleAuthor, cryptoId: MY_AGENT_ID } };
const ownItem = { post: ownPost, score: 0.9, reason: 'own' };
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [ownItem], count: 1 });
@@ -682,7 +681,9 @@ describe('delete actions', () => {
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
+ // Opens the in-app confirm modal; the RPC fires only after confirming.
await user.click(screen.getByText('Delete'));
+ await user.click(await screen.findByTestId('confirm-dialog-confirm'));
await waitFor(() => {
expect(vi.mocked(apiClient.feeds.deletePost)).toHaveBeenCalledWith(ownPost.postId);
});
@@ -704,7 +705,6 @@ describe('delete actions', () => {
test('delete comment calls feeds.deleteComment then refetches detail', async () => {
const user = userEvent.setup();
- vi.spyOn(window, 'confirm').mockReturnValue(true);
// comment author is the current user
const myComment = {
...sampleComment,
@@ -726,6 +726,8 @@ describe('delete actions', () => {
});
const deleteBtn = screen.getByText('Delete');
await user.click(deleteBtn);
+ // Confirm in the in-app modal before the delete RPC fires.
+ await user.click(await screen.findByTestId('confirm-dialog-confirm'));
await waitFor(() => {
expect(vi.mocked(apiClient.feeds.deleteComment)).toHaveBeenCalledWith(
samplePost.author.handle,
diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx
index 42f0051fc0..63bd633793 100644
--- a/app/src/agentworld/pages/FeedSection.tsx
+++ b/app/src/agentworld/pages/FeedSection.tsx
@@ -12,7 +12,7 @@
* - Like / unlike toggle with optimistic update and server reconcile
* - Comment composer (adds comment, refetches detail via GraphQL)
* - Inline post composer at the top of the feed (refetches feed on success)
- * - Delete post / delete comment (own content only, with window.confirm)
+ * - Delete post / delete comment (own content only, via an in-app ConfirmDialog)
*
* Pattern mirrors ExploreSection / MarketplaceSection: useState + useEffect
* fetch, PanelScaffold wrapper, StatusBlock for loading/error/empty states.
@@ -31,6 +31,7 @@ import {
} from '../../lib/agentworld/invokeApiClient';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
+import ConfirmDialog from '../components/ConfirmDialog';
const log = debug('agentworld:feed');
@@ -530,6 +531,25 @@ function CommentRow({
postId: string;
onCommentDeleted: () => void;
}) {
+ // Drives the in-app confirm modal for comment deletion (#4197).
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+
+ const confirmDeleteComment = () => {
+ setDeleting(true);
+ void apiClient.feeds
+ .deleteComment(handle, postId, comment.commentId)
+ .then(({ ok }) => {
+ if (!ok) throw new Error('Comment deletion was not accepted by the backend');
+ onCommentDeleted();
+ })
+ .catch(err => console.error('[FeedSection] delete comment failed:', err))
+ .finally(() => {
+ setDeleting(false);
+ setConfirmingDelete(false);
+ });
+ };
+
return (
{comment.author.avatarUrl ? (
@@ -550,14 +570,7 @@ function CommentRow({
{myAgentId && comment.author.cryptoId === myAgentId && (
{comment.body}
+ {confirmingDelete && (
+ {
+ if (!deleting) setConfirmingDelete(false);
+ }}
+ />
+ )}
);
}
@@ -577,6 +602,10 @@ export default function FeedSection() {
const [followState, setFollowState] = useState>({});
const [followLoading, setFollowLoading] = useState>({});
const [likeState, setLikeState] = useState>({});
+ // Post pending deletion — drives the in-app confirm modal (#4197). `null` = no
+ // dialog open; `deletingPost` disables the buttons while the RPC is in flight.
+ const [postPendingDelete, setPostPendingDelete] = useState(null);
+ const [deletingPost, setDeletingPost] = useState(false);
const { agentId: myAgentId, configured: walletConfigured } = useWalletResolution();
@@ -707,17 +736,32 @@ export default function FeedSection() {
// ── Delete post ────────────────────────────────────────────────────────────
+ // Open the in-app confirm modal; the actual delete runs in `confirmDeletePost`
+ // only after the user confirms (replaces the native window.confirm — #4197).
const handleDeletePost = (post: GqlPost) => {
- if (!window.confirm('Delete this post?')) return;
+ setPostPendingDelete(post);
+ };
+
+ const confirmDeletePost = () => {
+ const post = postPendingDelete;
+ if (!post) return;
+ setDeletingPost(true);
void apiClient.feeds
.deletePost(post.postId)
- .then(() => {
- void apiClient.graphql.homeFeed({ limit: 50, includeSelf: true }).then(result => {
+ .then(({ ok }) => {
+ if (!ok) throw new Error('Post deletion was not accepted by the backend');
+ // Return the refresh promise so its rejection reaches `.catch` (rather
+ // than resolving the delete as "done" before the feed is reloaded).
+ return apiClient.graphql.homeFeed({ limit: 50, includeSelf: true }).then(result => {
const items = sortedHomeFeedItems(result);
setFeedState({ status: 'ok', items });
});
})
- .catch(err => console.error('[FeedSection] delete post failed:', err));
+ .catch(err => console.error('[FeedSection] delete post failed:', err))
+ .finally(() => {
+ setDeletingPost(false);
+ setPostPendingDelete(null);
+ });
};
// ── Refetch feed ───────────────────────────────────────────────────────────
@@ -807,6 +851,18 @@ export default function FeedSection() {
)}
{body}
+ {postPendingDelete && (
+ {
+ if (!deletingPost) setPostPendingDelete(null);
+ }}
+ />
+ )}
);
}