diff --git a/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx index 6d02962c..6a36b065 100644 --- a/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx +++ b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -7,6 +7,7 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import type { ForgeChecksResult, ForgeCommitsResult, + ForgeEntityRef, ForgeIssueDetail, ForgeProvider, ForgePullRequestContext, @@ -18,6 +19,12 @@ import { ForgeCommitsSection } from './ForgeCommitsSection'; import { ForgeFilesDiffSection } from './ForgeFilesDiffSection'; import { ForgeTimelineSection } from './ForgeTimelineSection'; import { ForgeChecksSection } from './ForgeChecksSection'; +import { + ForgeCommentComposer, + ForgeEntityActions, + ForgeMetadataEditor, + ForgeThreadReply, +} from './actions'; interface ForgeEntityDetailViewProps { provider: ForgeProvider; @@ -95,11 +102,32 @@ export const ForgeEntityDetailView: React.FC = ({ pr const [pull, setPull] = useState(null); const [issueDetail, setIssueDetail] = useState(null); const [isLoading, setIsLoading] = useState(true); + // Bumped after a successful write so the owning load effect re-runs; never + // bumped on render, so writes are the only trigger. + const [reloadToken, setReloadToken] = useState(0); + // Comments posted through this view are appended locally so they appear + // immediately; a later context refresh reconciles them with authoritative + // server data (and the load effect clears the local list). + const [localComments, setLocalComments] = useState([]); + // Id of the thread root the user is replying to (renders ForgeThreadReply + // under that thread card). + const [replyingTo, setReplyingTo] = useState(null); + + const reload = useCallback(() => { + setReloadToken((value) => value + 1); + }, []); + + const ref = useMemo(() => ({ kind: isIssue ? 'issue' : 'pull', number }), [isIssue, number]); + + const appendComment = useCallback((comment: ForgeComment) => { + setLocalComments((previous) => [...previous, comment]); + }, []); useEffect(() => { let cancelled = false; setPull(null); setIssueDetail(null); + setLocalComments([]); setIsLoading(true); if (isIssue) { @@ -147,13 +175,14 @@ export const ForgeEntityDetailView: React.FC = ({ pr return () => { cancelled = true; }; - }, [directory, isIssue, number, provider, sourceRepo]); + }, [directory, isIssue, number, provider, reloadToken, sourceRepo]); const mergedComments = useMemo(() => { - if (isIssue) return issueDetail?.comments ?? []; - const context = pull?.context; - return [...(context?.issueComments ?? []), ...(context?.reviewComments ?? [])]; - }, [isIssue, issueDetail?.comments, pull?.context]); + const derived = isIssue + ? issueDetail?.comments ?? [] + : [...(pull?.context?.issueComments ?? []), ...(pull?.context?.reviewComments ?? [])]; + return [...localComments, ...derived]; + }, [isIssue, issueDetail?.comments, localComments, pull?.context]); const timelineEvents = useMemo(() => pull?.timeline?.events ?? [], [pull?.timeline]); @@ -170,6 +199,32 @@ export const ForgeEntityDetailView: React.FC = ({ pr return null; }, [pull?.context, provider.capabilities.checks, pull?.checks]); + const canReply = typeof provider.replyToThread === 'function'; + + const handleReply = useCallback((comment: ForgeComment) => { + setReplyingTo(comment.id); + }, []); + + const renderThreadReply = useCallback( + (comment: ForgeComment): React.ReactNode => { + if (comment.id !== replyingTo) return null; + return ( + { + appendComment(created); + setReplyingTo(null); + }} + onCancel={() => setReplyingTo(null)} + /> + ); + }, + [appendComment, directory, provider, ref, replyingTo], + ); + if (isLoading) { return ; } @@ -194,7 +249,17 @@ export const ForgeEntityDetailView: React.FC = ({ pr {t(`forge.state.${issueState}`)} + + {issue.body ? ( ) : null} @@ -202,10 +267,13 @@ export const ForgeEntityDetailView: React.FC = ({ pr {t('forge.section.timeline')} + ); } @@ -242,6 +310,8 @@ export const ForgeEntityDetailView: React.FC = ({ pr ) : null} + + {checksForPull ? ( @@ -273,8 +343,11 @@ export const ForgeEntityDetailView: React.FC = ({ pr events={timelineEvents} comments={mergedComments} error={pull.timeline?.error ?? null} + onReply={canReply ? handleReply : undefined} + renderReply={canReply ? renderThreadReply : undefined} /> + ); }; diff --git a/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx b/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx index 32486503..fdb89211 100644 --- a/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx +++ b/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { useI18n } from '@/lib/i18n'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; @@ -13,6 +14,10 @@ interface ForgeTimelineSectionProps { comments: ForgeComment[]; loading?: boolean; error?: string | null; + /** Optional: asked when the user hits Reply on an inline-comment thread (its root comment). */ + onReply?: (comment: ForgeComment) => void; + /** Optional: rendered under a thread card the parent is replying to. */ + renderReply?: (comment: ForgeComment) => React.ReactNode; } const EVENT_ICONS: Record = { @@ -83,7 +88,7 @@ type TimelineItem = * by `inReplyToId` chains or (path, line) buckets; a thread renders as one * card with its comments stacked. Pure presentation. */ -export const ForgeTimelineSection = React.memo(function ForgeTimelineSection({ events, comments, loading, error }) { +export const ForgeTimelineSection = React.memo(function ForgeTimelineSection({ events, comments, loading, error, onReply, renderReply }) { const { t } = useI18n(); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); @@ -252,6 +257,19 @@ export const ForgeTimelineSection = React.memo(functi ))} + {root.path && onReply ? ( +
+ +
+ ) : null} + {renderReply ? renderReply(root) : null} ); diff --git a/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx b/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx new file mode 100644 index 00000000..4a86ddee --- /dev/null +++ b/packages/ui/src/components/views/forge/actions/ForgeCommentComposer.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from '@/components/ui/toast'; +import { useI18n } from '@/lib/i18n'; +import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider'; +import type { ForgeComment } from '@/lib/forge/types'; + +interface ForgeCommentComposerProps { + provider: ForgeProvider; + directory: string; + ref: ForgeEntityRef; + onPosted?: (comment: ForgeComment) => void; +} + +/** + * Comment composer for an issue or pull request thread. Renders nothing when + * the provider has no `addComment` method. Posts through the facade and reports + * the created comment via `onPosted`; failures toast a stable message. + */ +export const ForgeCommentComposer: React.FC = ({ provider, directory, ref, onPosted }) => { + const { t } = useI18n(); + const [body, setBody] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const addComment = provider.addComment; + if (!addComment) return null; + + const canSubmit = body.trim().length > 0 && !submitting; + + const submit = async (): Promise => { + if (!canSubmit) return; + setSubmitting(true); + try { + const result = await addComment(directory, ref, { body: body.trim() }); + if (!result.ok) { + toast.error(t('forge.actions.error')); + return; + } + setBody(''); + if (result.comment) onPosted?.(result.comment); + } finally { + setSubmitting(false); + } + }; + + return ( +
+