feat(ui): forge write operations — comments, replies, close/reopen, edit, reviews, draft, metadata

- server: write routes for all three providers (issue/PR comments, inline review-comment replies, issue/MR updates w/ labels-assignees-milestone, review submit, draft toggle)
- ui: ForgeProvider gains six write ops; shared action components (composer, thread reply, state/review/draft/metadata/edit) wired into ForgeEntityDetailView and GitHub PR Overview
This commit is contained in:
2026-08-16 16:29:24 +00:00
parent 92f0eced34
commit 8f5cfdcd62
44 changed files with 5383 additions and 62 deletions
@@ -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<ForgeEntityDetailViewProps> = ({ pr
const [pull, setPull] = useState<PullData | null>(null);
const [issueDetail, setIssueDetail] = useState<ForgeIssueDetail | null>(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<ForgeComment[]>([]);
// Id of the thread root the user is replying to (renders ForgeThreadReply
// under that thread card).
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const reload = useCallback(() => {
setReloadToken((value) => value + 1);
}, []);
const ref = useMemo<ForgeEntityRef>(() => ({ 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<ForgeEntityDetailViewProps> = ({ pr
return () => {
cancelled = true;
};
}, [directory, isIssue, number, provider, sourceRepo]);
}, [directory, isIssue, number, provider, reloadToken, sourceRepo]);
const mergedComments = useMemo<ForgeComment[]>(() => {
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<ForgeTimelineEvent[]>(() => pull?.timeline?.events ?? [], [pull?.timeline]);
@@ -170,6 +199,32 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ 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 (
<ForgeThreadReply
provider={provider}
directory={directory}
ref={ref}
thread={{ inReplyToId: comment.id, path: comment.path ?? null, line: comment.line ?? null }}
onPosted={(created) => {
appendComment(created);
setReplyingTo(null);
}}
onCancel={() => setReplyingTo(null)}
/>
);
},
[appendComment, directory, provider, ref, replyingTo],
);
if (isLoading) {
return <LoadingBlock label={t('forge.loading')} />;
}
@@ -194,7 +249,17 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
{t(`forge.state.${issueState}`)}
</span>
</div>
<ForgeEntityActions provider={provider} directory={directory} ref={ref} issue={issue} onChanged={reload} />
<ForgeMetadataChips kind="issue" issue={issue} />
<ForgeMetadataEditor
provider={provider}
directory={directory}
ref={ref}
labels={issue.labels ?? []}
assignees={issue.assignees ?? []}
milestone={issue.milestone}
onChanged={reload}
/>
{issue.body ? (
<SimpleMarkdownRenderer content={issue.body} className={markdownClassName} enableFileReferences={false} />
) : null}
@@ -202,10 +267,13 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
<SectionTitle>{t('forge.section.timeline')}</SectionTitle>
<ForgeTimelineSection
events={[]}
comments={issueDetail.comments ?? []}
comments={mergedComments}
error={issueDetail.commentsError ?? null}
onReply={canReply ? handleReply : undefined}
renderReply={canReply ? renderThreadReply : undefined}
/>
</section>
<ForgeCommentComposer provider={provider} directory={directory} ref={ref} onPosted={appendComment} />
</div>
);
}
@@ -242,6 +310,8 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
) : null}
</div>
<ForgeEntityActions provider={provider} directory={directory} ref={ref} pr={pr} onChanged={reload} />
<ForgeMetadataChips kind="pull" pr={pr} />
{checksForPull ? (
@@ -273,8 +343,11 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
events={timelineEvents}
comments={mergedComments}
error={pull.timeline?.error ?? null}
onReply={canReply ? handleReply : undefined}
renderReply={canReply ? renderThreadReply : undefined}
/>
</section>
<ForgeCommentComposer provider={provider} directory={directory} ref={ref} onPosted={appendComment} />
</div>
);
};
@@ -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<ForgeTimelineEventType, IconName> = {
@@ -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<ForgeTimelineSectionProps>(function ForgeTimelineSection({ events, comments, loading, error }) {
export const ForgeTimelineSection = React.memo<ForgeTimelineSectionProps>(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<ForgeTimelineSectionProps>(functi
</div>
))}
</div>
{root.path && onReply ? (
<div className="flex items-center gap-1.5 pt-2">
<Button
variant="link"
size="xs"
onClick={() => onReply(root)}
aria-label={t('forge.actions.reply')}
>
{t('forge.actions.reply')}
</Button>
</div>
) : null}
{renderReply ? renderReply(root) : null}
</div>
</div>
);
@@ -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<ForgeCommentComposerProps> = ({ 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<void> => {
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 (
<div className="flex flex-col gap-2">
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('forge.actions.commentPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.commentPlaceholder')}
className="min-h-[72px]"
/>
<div className="flex justify-end">
<Button size="sm" onClick={() => void submit()} disabled={!canSubmit}>
{submitting ? (
<>
<Icon name="loader-4" className="size-4 animate-spin" />
{t('forge.actions.posting')}
</>
) : (
<>
<Icon name="chat-1" className="size-4" />
{t('forge.actions.comment')}
</>
)}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
interface ForgeDraftToggleProps {
provider: ForgeProvider;
directory: string;
ref: ForgeEntityRef;
draft: boolean;
onChanged?: (draft: boolean) => void;
}
/**
* Draft <-> ready toggle for a pull request. Renders nothing unless the
* provider supports drafts (`capabilities.draft`) and implements
* `toggleDraft`. Marks the PR ready when it is a draft, and back to draft
* otherwise.
*/
export const ForgeDraftToggle: React.FC<ForgeDraftToggleProps> = ({ provider, directory, ref, draft, onChanged }) => {
const { t } = useI18n();
const [submitting, setSubmitting] = useState(false);
const toggleDraft = provider.toggleDraft;
if (!toggleDraft || !provider.capabilities.draft) return null;
const nextDraft = !draft;
const run = async (): Promise<void> => {
if (submitting) return;
setSubmitting(true);
try {
const result = await toggleDraft(directory, ref, nextDraft);
if (!result.ok) {
toast.error(t('forge.actions.error'));
return;
}
toast.success(t('forge.actions.draftChanged'));
onChanged?.(nextDraft);
} finally {
setSubmitting(false);
}
};
return (
<Button
variant="outline"
size="sm"
onClick={() => void run()}
disabled={submitting}
aria-label={t(draft ? 'forge.actions.markReady' : 'forge.actions.markDraft')}
>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name={draft ? 'checkbox-circle' : 'git-pr-draft'} className="size-4" />
)}
{t(draft ? 'forge.actions.markReady' : 'forge.actions.markDraft')}
</Button>
);
};
@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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';
interface ForgeEditFormProps {
provider: ForgeProvider;
directory: string;
ref: ForgeEntityRef;
title: string;
body?: string;
onSaved?: () => void;
onCancel?: () => void;
}
/**
* Inline edit form for an issue/PR title and body. Renders nothing when the
* provider has no `updateEntity` method. Saves both fields in one write and
* reports through `onSaved`.
*/
export const ForgeEditForm: React.FC<ForgeEditFormProps> = ({ provider, directory, ref, title, body, onSaved, onCancel }) => {
const { t } = useI18n();
const [editTitle, setEditTitle] = useState(title);
const [editBody, setEditBody] = useState(body ?? '');
const [submitting, setSubmitting] = useState(false);
const updateEntity = provider.updateEntity;
if (!updateEntity) return null;
const canSubmit = editTitle.trim().length > 0 && !submitting;
const save = async (): Promise<void> => {
if (!canSubmit) return;
setSubmitting(true);
try {
const result = await updateEntity(directory, ref, { title: editTitle.trim(), body: editBody });
if (!result.ok) {
toast.error(t('forge.actions.error'));
return;
}
toast.success(t('forge.actions.updated'));
onSaved?.();
} finally {
setSubmitting(false);
}
};
return (
<div className="flex flex-col gap-2">
<Input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
placeholder={t('forge.actions.edit')}
aria-label={t('forge.actions.edit')}
disabled={submitting}
/>
<Textarea
value={editBody}
onChange={(event) => setEditBody(event.target.value)}
placeholder={t('forge.actions.commentPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.commentPlaceholder')}
className="min-h-[96px]"
/>
<div className="flex items-center justify-end gap-1.5">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={submitting}>
{t('forge.actions.cancel')}
</Button>
<Button size="sm" onClick={() => void save()} disabled={!canSubmit}>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="check" className="size-4" />
)}
{t('forge.actions.save')}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,106 @@
import React, { useCallback, useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
import type { ForgeEntityState, ForgeIssue, ForgePullRequest } from '@/lib/forge/types';
import { ForgeDraftToggle } from './ForgeDraftToggle';
import { ForgeEditForm } from './ForgeEditForm';
import { ForgeReviewActions } from './ForgeReviewActions';
import { ForgeStateActions } from './ForgeStateActions';
interface ForgeEntityActionsProps {
provider: ForgeProvider;
directory: string;
ref: ForgeEntityRef;
pr?: ForgePullRequest | null;
issue?: ForgeIssue | null;
onChanged?: () => void;
}
/**
* Header action bar for a forge issue or pull request: Edit (expands an inline
* form), draft toggle + review actions (pulls only), and close/reopen. Each
* affordance is capability- and method-gated; the bar renders nothing when no
* write operation applies. Successes funnel through `onChanged` so the owning
* view can refetch.
*/
export const ForgeEntityActions: React.FC<ForgeEntityActionsProps> = ({ provider, directory, ref, pr, issue, onChanged }) => {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
const entity = pr ?? issue;
const entityState: ForgeEntityState = entity?.state ?? 'open';
const isPull = ref.kind === 'pull';
const updateEntity = provider.updateEntity;
const hasEdit = Boolean(updateEntity) && Boolean(entity);
const hasDraft = isPull && provider.capabilities.draft && typeof provider.toggleDraft === 'function';
const hasState = Boolean(updateEntity) && entityState !== 'merged';
const hasReview = isPull && provider.capabilities.reviews !== 'none' && typeof provider.submitReview === 'function';
const showOtherActions = !editing && (hasDraft || hasState || hasReview);
const onSaved = useCallback(() => {
setEditing(false);
onChanged?.();
}, [onChanged]);
if (!hasEdit && !showOtherActions) return null;
return (
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{hasEdit && !editing ? (
<Button
variant="outline"
size="sm"
onClick={() => setEditing(true)}
aria-label={t('forge.actions.edit')}
>
<Icon name="edit" className="size-4" />
{t('forge.actions.edit')}
</Button>
) : null}
{hasEdit && showOtherActions ? <div className="h-4 w-px shrink-0 bg-border/60" aria-hidden /> : null}
{showOtherActions ? (
<>
{hasDraft && pr ? (
<ForgeDraftToggle
provider={provider}
directory={directory}
ref={ref}
draft={pr.draft}
onChanged={onChanged}
/>
) : null}
{hasState ? (
<ForgeStateActions
provider={provider}
directory={directory}
ref={ref}
state={entityState}
onChanged={onChanged}
/>
) : null}
{hasReview ? <ForgeReviewActions provider={provider} directory={directory} ref={ref} onReviewed={onChanged} /> : null}
</>
) : null}
</div>
{editing && entity ? (
<ForgeEditForm
provider={provider}
directory={directory}
ref={ref}
title={entity.title}
body={entity.body}
onSaved={onSaved}
onCancel={() => setEditing(false)}
/>
) : null}
</div>
);
};
@@ -0,0 +1,245 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
import type { ForgeLabel, ForgeMilestone, ForgeUser } from '@/lib/forge/types';
interface ForgeMetadataEditorProps {
provider: ForgeProvider;
directory: string;
ref: ForgeEntityRef;
labels: ForgeLabel[];
assignees: ForgeUser[];
milestone: ForgeMilestone | null | undefined;
onChanged?: () => void;
}
const chipClassName =
'inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-surface-elevated px-2 py-0.5 typography-micro text-foreground';
const removeButtonClassName =
'inline-flex size-4 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-50';
const avatarSize = 'size-3.5 rounded-full';
/** GitHub label colors arrive without the `#` prefix; normalize both spellings. */
const resolveLabelColor = (color?: string): string | null => {
if (!color) return null;
const value = color.trim();
if (!value) return null;
return value.startsWith('#') ? value : `#${value}`;
};
/**
* Metadata editor for an issue: labels / assignees / milestone chips with a
* remove affordance plus per-category add inputs. Every write replaces the
* full set of the changed field only (`provider.updateMetadata` semantics).
* Renders nothing when `updateMetadata` is missing or no category is enabled.
*/
export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
provider,
directory,
ref,
labels,
assignees,
milestone,
onChanged,
}) => {
const { t } = useI18n();
const [labelInput, setLabelInput] = useState('');
const [assigneeInput, setAssigneeInput] = useState('');
const [milestoneInput, setMilestoneInput] = useState('');
const [submitting, setSubmitting] = useState(false);
const updateMetadata = provider.updateMetadata;
const canLabels = Boolean(updateMetadata) && provider.capabilities.labels;
const canAssignees = Boolean(updateMetadata) && provider.capabilities.assignees;
const canMilestones = Boolean(updateMetadata) && provider.capabilities.milestones;
if (!updateMetadata || (!canLabels && !canAssignees && !canMilestones)) return null;
const runMetadata = async (
input: { labels?: string[]; assignees?: string[]; milestone?: string | null },
successKey: I18nKey,
): Promise<void> => {
if (submitting) return;
setSubmitting(true);
try {
const result = await updateMetadata(directory, ref, input);
if (!result.ok) {
toast.error(t('forge.actions.error'));
return;
}
toast.success(t(successKey));
onChanged?.();
} finally {
setSubmitting(false);
}
};
const addLabel = async (): Promise<void> => {
const name = labelInput.trim();
if (!name) return;
await runMetadata({ labels: [...labels.map((label) => label.name), name] }, 'forge.actions.added');
setLabelInput('');
};
const removeLabel = async (name: string): Promise<void> => {
await runMetadata({ labels: labels.filter((label) => label.name !== name).map((label) => label.name) }, 'forge.actions.removed');
};
const addAssignee = async (): Promise<void> => {
const login = assigneeInput.trim();
if (!login) return;
await runMetadata({ assignees: [...assignees.map((assignee) => assignee.login), login] }, 'forge.actions.added');
setAssigneeInput('');
};
const removeAssignee = async (id: string): Promise<void> => {
await runMetadata({ assignees: assignees.filter((assignee) => assignee.id !== id).map((assignee) => assignee.login) }, 'forge.actions.removed');
};
const addMilestone = async (): Promise<void> => {
const title = milestoneInput.trim();
if (!title) return;
await runMetadata({ milestone: title }, 'forge.actions.metadataChanged');
setMilestoneInput('');
};
const removeMilestone = async (): Promise<void> => {
await runMetadata({ milestone: null }, 'forge.actions.removed');
};
const renderAvatar = (user: ForgeUser): React.ReactElement => {
if (user.avatarUrl) {
return <img src={user.avatarUrl} alt={user.login} className={`${avatarSize} object-cover`} />;
}
return (
<span className={`${avatarSize} flex items-center justify-center bg-interactive-hover text-[10px] font-medium text-foreground`}>
{(user.login || user.name || '?').charAt(0).toUpperCase()}
</span>
);
};
return (
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{canLabels
? labels.map((label) => (
<span key={label.name} className={chipClassName} title={label.description || label.name}>
<span
aria-hidden
className="size-2 rounded-full"
style={{ backgroundColor: resolveLabelColor(label.color) ?? 'var(--status-info)' }}
/>
{label.name}
<button
type="button"
className={removeButtonClassName}
onClick={() => void removeLabel(label.name)}
disabled={submitting}
aria-label={`${t('forge.actions.remove')}: ${label.name}`}
>
<Icon name="close" className="size-3" />
</button>
</span>
))
: null}
{canAssignees
? assignees.map((assignee) => (
<span key={assignee.id} className={chipClassName} title={assignee.login}>
{renderAvatar(assignee)}
{assignee.login}
<button
type="button"
className={removeButtonClassName}
onClick={() => void removeAssignee(assignee.id)}
disabled={submitting}
aria-label={`${t('forge.actions.remove')}: ${assignee.login}`}
>
<Icon name="close" className="size-3" />
</button>
</span>
))
: null}
{canMilestones && milestone ? (
<span className={chipClassName} title={milestone.title}>
<Icon name="target" className="size-3 text-muted-foreground" />
{milestone.title}
<button
type="button"
className={removeButtonClassName}
onClick={() => void removeMilestone()}
disabled={submitting}
aria-label={`${t('forge.actions.remove')}: ${milestone.title}`}
>
<Icon name="close" className="size-3" />
</button>
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{canLabels ? (
<span className="flex items-center gap-1">
<Input
value={labelInput}
onChange={(event) => setLabelInput(event.target.value)}
placeholder={t('forge.actions.addLabel')}
aria-label={t('forge.actions.addLabel')}
className="h-6 w-36"
onKeyDown={(event) => {
if (event.key === 'Enter') void addLabel();
}}
/>
<Button variant="ghost" size="xs" onClick={() => void addLabel()} disabled={submitting || !labelInput.trim()}>
{t('forge.actions.addLabel')}
</Button>
</span>
) : null}
{canAssignees ? (
<span className="flex items-center gap-1">
<Input
value={assigneeInput}
onChange={(event) => setAssigneeInput(event.target.value)}
placeholder={t('forge.actions.addAssignee')}
aria-label={t('forge.actions.addAssignee')}
className="h-6 w-36"
onKeyDown={(event) => {
if (event.key === 'Enter') void addAssignee();
}}
/>
<Button variant="ghost" size="xs" onClick={() => void addAssignee()} disabled={submitting || !assigneeInput.trim()}>
{t('forge.actions.addAssignee')}
</Button>
</span>
) : null}
{canMilestones ? (
<span className="flex items-center gap-1">
<Input
value={milestoneInput}
onChange={(event) => setMilestoneInput(event.target.value)}
placeholder={t('forge.actions.setMilestone')}
aria-label={t('forge.actions.setMilestone')}
className="h-6 w-36"
onKeyDown={(event) => {
if (event.key === 'Enter') void addMilestone();
}}
/>
<Button variant="ghost" size="xs" onClick={() => void addMilestone()} disabled={submitting || !milestoneInput.trim()}>
{t('forge.actions.setMilestone')}
</Button>
</span>
) : null}
</div>
</div>
);
};
@@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider, ForgeReviewEvent } from '@/lib/forge/provider';
interface ForgeReviewActionsProps {
provider: ForgeProvider;
directory: string;
ref: ForgeEntityRef;
onReviewed?: () => void;
}
const EVENT_LABEL_KEYS: Record<ForgeReviewEvent, I18nKey> = {
approve: 'forge.actions.approve',
'request-changes': 'forge.actions.requestChanges',
comment: 'forge.actions.reviewComment',
};
/**
* Review submission controls for a pull request. Renders nothing unless the
* provider exposes reviews (`capabilities.reviews !== 'none'`) and a
* `submitReview` method. `approve-only` providers (GitLab) get a single direct
* Approve button; `submit` providers (GitHub/Gitea) get Approve / Request
* changes / Comment, each opening a small dialog with an optional body.
*/
export const ForgeReviewActions: React.FC<ForgeReviewActionsProps> = ({ provider, directory, ref, onReviewed }) => {
const { t } = useI18n();
const [pendingEvent, setPendingEvent] = useState<ForgeReviewEvent | null>(null);
const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false);
const submitReview = provider.submitReview;
if (!submitReview || provider.capabilities.reviews === 'none') return null;
const canRequestChanges = provider.capabilities.reviews === 'submit';
const openDialog = (event: ForgeReviewEvent): void => {
setBody('');
setPendingEvent(event);
};
const submit = async (): Promise<void> => {
if (!pendingEvent || submitting) return;
setSubmitting(true);
try {
const result = await submitReview(directory, ref, {
event: pendingEvent,
...(body.trim() ? { body: body.trim() } : {}),
});
if (!result.ok) {
toast.error(t('forge.actions.error'));
return;
}
toast.success(t('forge.actions.reviewed'));
setPendingEvent(null);
setBody('');
onReviewed?.();
} finally {
setSubmitting(false);
}
};
const renderEventButton = (event: ForgeReviewEvent): React.ReactElement => (
<Button variant="outline" size="sm" onClick={() => openDialog(event)} disabled={submitting}>
{event === 'approve' ? <Icon name="checkbox-circle" className="size-4" /> : <Icon name="chat-1" className="size-4" />}
{t(EVENT_LABEL_KEYS[event])}
</Button>
);
return (
<>
<div className="flex flex-wrap items-center gap-1.5">
{renderEventButton('approve')}
{canRequestChanges ? (
<>
{renderEventButton('request-changes')}
{renderEventButton('comment')}
</>
) : null}
</div>
<Dialog
open={pendingEvent !== null}
onOpenChange={(open) => {
if (!open) setPendingEvent(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('forge.actions.reviewDialogTitle')}</DialogTitle>
</DialogHeader>
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('forge.actions.reviewBodyPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.reviewBodyPlaceholder')}
className="min-h-[96px]"
/>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setPendingEvent(null)} disabled={submitting}>
{t('forge.actions.cancel')}
</Button>
<Button size="sm" onClick={() => void submit()} disabled={submitting}>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="check" className="size-4" />
)}
{pendingEvent ? t(EVENT_LABEL_KEYS[pendingEvent]) : t('forge.actions.reviewDialogTitle')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import type { ForgeEntityRef, ForgeProvider, ForgeWriteState } from '@/lib/forge/provider';
import type { ForgeEntityState } from '@/lib/forge/types';
interface ForgeStateActionsProps {
provider: ForgeProvider;
directory: string;
ref: ForgeEntityRef;
state: ForgeEntityState;
onChanged?: (state: ForgeEntityState) => void;
}
/**
* Close/reopen control for an issue or pull request. Renders nothing when the
* provider has no `updateEntity` method, or when the entity is merged (a
* terminal, non-writable state). Closing asks for confirmation first.
*/
export const ForgeStateActions: React.FC<ForgeStateActionsProps> = ({ provider, directory, ref, state, onChanged }) => {
const { t } = useI18n();
const [submitting, setSubmitting] = useState(false);
const updateEntity = provider.updateEntity;
if (!updateEntity || state === 'merged') return null;
const isOpen = state === 'open';
const nextState: ForgeWriteState = isOpen ? 'closed' : 'open';
const run = async (): Promise<void> => {
if (submitting) return;
if (isOpen && !window.confirm(t('forge.actions.closeConfirm'))) return;
setSubmitting(true);
try {
const result = await updateEntity(directory, ref, { state: nextState });
if (!result.ok) {
toast.error(t('forge.actions.error'));
return;
}
toast.success(t('forge.actions.stateChanged'));
onChanged?.(nextState);
} finally {
setSubmitting(false);
}
};
return (
<Button
variant="outline"
size="sm"
onClick={() => void run()}
disabled={submitting}
aria-label={t(isOpen ? 'forge.actions.close' : 'forge.actions.reopen')}
>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name={isOpen ? 'git-close-pull-request' : 'git-pull-request'} className="size-4" />
)}
{t(isOpen ? 'forge.actions.close' : 'forge.actions.reopen')}
</Button>
);
};
@@ -0,0 +1,89 @@
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';
/** Anchor of the thread being replied to (see `ForgeComment.inReplyToId`/`path`/`line`). */
export interface ForgeThreadTarget {
inReplyToId: string;
path?: string | null;
line?: number | null;
}
interface ForgeThreadReplyProps {
provider: ForgeProvider;
directory: string;
ref: ForgeEntityRef;
thread: ForgeThreadTarget;
onPosted?: (comment: ForgeComment) => void;
onCancel?: () => void;
}
/**
* Inline reply editor for one comment thread. Renders nothing when the
* provider has no `replyToThread` method. The parent decides when the editor
* is visible (expansion is driven from outside); posting clears the editor and
* reports the created comment via `onPosted`.
*/
export const ForgeThreadReply: React.FC<ForgeThreadReplyProps> = ({ provider, directory, ref, thread, onPosted, onCancel }) => {
const { t } = useI18n();
const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false);
const replyToThread = provider.replyToThread;
if (!replyToThread) return null;
const canSubmit = body.trim().length > 0 && !submitting;
const submit = async (): Promise<void> => {
if (!canSubmit) return;
setSubmitting(true);
try {
const result = await replyToThread(directory, ref, {
body: body.trim(),
inReplyToId: thread.inReplyToId,
path: thread.path ?? null,
line: thread.line ?? null,
});
if (!result.ok) {
toast.error(t('forge.actions.error'));
return;
}
setBody('');
if (result.comment) onPosted?.(result.comment);
} finally {
setSubmitting(false);
}
};
return (
<div className="flex flex-col gap-1.5">
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('forge.actions.commentPlaceholder')}
disabled={submitting}
aria-label={t('forge.actions.commentPlaceholder')}
className="min-h-[56px]"
autoFocus
/>
<div className="flex items-center justify-end gap-1.5">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={submitting}>
{t('forge.actions.cancel')}
</Button>
<Button size="sm" onClick={() => void submit()} disabled={!canSubmit}>
{submitting ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="chat-1" className="size-4" />
)}
{t('forge.actions.reply')}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,18 @@
/**
* Write-action UI for forge issues and pull requests.
*
* Every component is capability- and method-gated: it renders nothing (or a
* sub-affordance) unless the provider implements the underlying write method
* and its capability flag is set. Components call the facade directly, toast
* stable i18n messages on failure (never raw error text), and report success
* through `onChanged`/`onPosted` callbacks so the owning view can refetch or
* update local state.
*/
export { ForgeCommentComposer } from './ForgeCommentComposer';
export { ForgeThreadReply } from './ForgeThreadReply';
export { ForgeStateActions } from './ForgeStateActions';
export { ForgeReviewActions } from './ForgeReviewActions';
export { ForgeDraftToggle } from './ForgeDraftToggle';
export { ForgeMetadataEditor } from './ForgeMetadataEditor';
export { ForgeEditForm } from './ForgeEditForm';
export { ForgeEntityActions } from './ForgeEntityActions';
@@ -34,6 +34,12 @@ import { summarizeCheckRuns } from '@/lib/githubChecks';
import { buildForgeProvider, mapGithubPr } from '@/lib/forge';
import type { ForgeCommit, ForgeFileChange } from '@/lib/forge';
import { ForgeCommitsSection, ForgeFilesDiffSection, ForgeMetadataChips } from '@/components/views/forge';
import {
ForgeCommentComposer,
ForgeDraftToggle,
ForgeReviewActions,
ForgeStateActions,
} from '@/components/views/forge/actions';
import type {
GitHubPullRequest,
GitHubCheckRun,
@@ -1182,6 +1188,19 @@ export const PullRequestSection: React.FC<{
}, delayMs));
}, [refresh]);
// Forge write actions in the Overview refresh the status store so chips,
// checks, and the header stay coherent after a state/draft/review change.
const refreshPr = React.useCallback(() => {
void refresh({ force: true });
}, [refresh]);
// A posted comment lives in the context store (Comments tab), so refresh it
// in place; the status store is unaffected by comments.
const refreshPrContext = React.useCallback(() => {
if (!github?.prContext || !pr) return;
void ensurePrContext(github, directory, pr.number, { force: true, sourceRepo: status?.repo ?? null });
}, [directory, ensurePrContext, github, pr, status?.repo]);
React.useEffect(() => {
if (!github?.prStatus || !canShow || remotes.length <= 1) {
return;
@@ -1869,6 +1888,31 @@ export const PullRequestSection: React.FC<{
{forgePr ? <ForgeMetadataChips kind="pull" pr={forgePr} /> : null}
{forgeProvider && forgePr && forgePr.state === 'open' ? (
<div className="flex flex-wrap items-center gap-1.5">
<ForgeDraftToggle
provider={forgeProvider}
directory={directory}
ref={{ kind: 'pull', number: pr.number }}
draft={!!forgePr.draft}
onChanged={refreshPr}
/>
<ForgeStateActions
provider={forgeProvider}
directory={directory}
ref={{ kind: 'pull', number: pr.number }}
state={forgePr.state}
onChanged={refreshPr}
/>
<ForgeReviewActions
provider={forgeProvider}
directory={directory}
ref={{ kind: 'pull', number: pr.number }}
onReviewed={refreshPr}
/>
</div>
) : null}
{isEditingPr ? (
<Textarea
value={editBody}
@@ -1894,6 +1938,15 @@ export const PullRequestSection: React.FC<{
</div>
)
) : null}
{forgeProvider && forgePr && forgePr.state === 'open' ? (
<ForgeCommentComposer
provider={forgeProvider}
directory={directory}
ref={{ kind: 'pull', number: pr.number }}
onPosted={refreshPrContext}
/>
) : null}
</div>
) : null}