main
import { Alert, Markdown } from '@app/components'
import type {
Pull,
PullComment,
PullCommentThread,
PullEvent,
PullRef,
PullReview,
ReviewIntent,
} from '@app/domains/pulls/model'
import {
createComment,
deleteComment,
dismissReview,
getReview,
listCommentTree,
listEvents,
listReviews,
publishReview,
resolveComment,
submitReview,
unresolveComment,
updateComment,
} from '@app/domains/pulls/model'
import { type MessageKey, useT } from '@app/i18n'
import { fmtRelative, fmtTime, useAsync } from '@app/lib'
import { Button } from '@app/ui'
import {
IconCheck as Check,
IconChecks as CheckCheck,
IconLock as Lock,
IconMessage as MessageSquare,
IconPencil as Pencil,
IconArrowBackUp as Reply,
IconRotate as RotateCcw,
IconSend as Send,
IconTrash as Trash2,
} from '@tabler/icons-react'
import { type ReactNode, useMemo, useState } from 'react'
import { eventLabel, REVIEW_STATE_LABEL, REVIEW_STATE_TONE, TONE, userName } from '../model/meta'
import { Avatar, Copyable, SideSection } from './parts'
type Row =
| { kind: 'thread'; at: string; thread: PullCommentThread }
| { kind: 'review'; at: string; review: PullReview }
| { kind: 'event'; at: string; event: PullEvent }
export interface ConversationProps {
refObj: PullRef
pull: Pull
onChanged: () => void
/** Jump to the files tab anchored at a path (from an inline comment). */
onOpenFile: (path: string) => void
/** 时间线末尾、评论框之前的插槽(合并盒等页面级操作)。 */
beforeComposer?: ReactNode
}
/** 会话页签:评论线程 + 评审卡片 + 事件时间线,按时间归并。 */
export function Conversation({ refObj, pull, onChanged, onOpenFile, beforeComposer }: ConversationProps) {
const { t } = useT()
const [tick, setTick] = useState(0)
const refresh = () => {
setTick((t) => t + 1)
onChanged()
}
const key = [refObj.space, refObj.repo, refObj.pull, tick]
const threads = useAsync(() => listCommentTree(refObj), key, undefined)
const events = useAsync(() => listEvents(refObj, { limit: 100 }), key, undefined)
const reviews = useAsync(() => listReviews(refObj), key, undefined)
const timeline = useMemo<Row[]>(() => {
const rows: Row[] = []
for (const thread of threads.data ?? []) rows.push({ kind: 'thread', at: thread.root.created_at, thread })
for (const review of reviews.data ?? []) rows.push({ kind: 'review', at: review.created_at, review })
for (const event of events.data?.items ?? []) rows.push({ kind: 'event', at: event.created_at, event })
return rows.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
}, [threads.data, reviews.data, events.data])
return (
<div className="space-y-4">
{pull.locked ? (
<Alert tone="info">
<span className="inline-flex items-center gap-1">
<Lock className="h-3.5 w-3.5" aria-hidden />
{t('pull.conv.locked')}
</span>
</Alert>
) : null}
{timeline.map((row) =>
row.kind === 'thread' ? (
<ThreadCard
key={`t:${row.thread.root.id}`}
refObj={refObj}
thread={row.thread}
onChanged={refresh}
onOpenFile={onOpenFile}
/>
) : row.kind === 'review' ? (
<ReviewCard key={`r:${row.review.id}`} refObj={refObj} review={row.review} onChanged={refresh} />
) : (
<EventRow key={`e:${row.event.id}`} event={row.event} />
),
)}
{beforeComposer}
<Composer refObj={refObj} pull={pull} onPosted={refresh} />
</div>
)
}
/* -------------------------------------------------------------- threads */
function CommentBubble({
refObj,
comment,
onChanged,
}: {
refObj: PullRef
comment: PullComment
onChanged: () => void
}) {
const { t } = useT()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(comment.body)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function save() {
setBusy(true)
setError(null)
try {
await updateComment(refObj, comment.id, draft)
setEditing(false)
onChanged()
} catch (failure) {
setError(failure instanceof Error ? failure.message : t('pull.conv.saveFailed'))
} finally {
setBusy(false)
}
}
async function remove() {
if (!window.confirm(t('pull.conv.deleteConfirm'))) return
try {
await deleteComment(refObj, comment.id)
onChanged()
} catch {
/* best-effort */
}
}
return (
<div className="flex gap-2.5">
<Avatar user={comment.author} size={24} />
<div className="min-w-0 flex-1 px-0.5">
<div className="flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
<span className="text-[12.5px] font-semibold text-shell-ink">{userName(comment.author)}</span>
<span title={fmtTime(comment.created_at)}>{fmtRelative(comment.created_at)}</span>
{comment.updated_at && comment.updated_at !== comment.created_at ? (
<span>{t('pull.conv.edited')}</span>
) : null}
<span className="ml-auto flex items-center gap-1">
<button
type="button"
title={t('pull.conv.edit')}
onClick={() => {
setDraft(comment.body)
setEditing((v) => !v)
}}
className="p-1 text-shell-ink-muted hover:text-shell-ink"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
title={t('pull.conv.delete')}
onClick={() => void remove()}
className="p-1 text-shell-ink-muted hover:text-[#b42318]"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
{editing ? (
<div className="mt-1.5">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={3}
className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-surface px-2.5 py-1.5 text-[13px] outline-none focus:border-shell-ink"
/>
{error ? <p className="mt-1 text-[12px] text-[#b42318]">{error}</p> : null}
<div className="mt-1.5 flex gap-2">
<Button className="h-7 text-[12px]" disabled={busy} onClick={() => void save()}>
{t('common.action.save')}
</Button>
<Button variant="ghost" className="h-7 text-[12px]" onClick={() => setEditing(false)}>
{t('common.action.cancel')}
</Button>
</div>
</div>
) : (
<Markdown className="mt-1">{comment.body}</Markdown>
)}
</div>
</div>
)
}
function ThreadCard({
refObj,
thread,
onChanged,
onOpenFile,
}: {
refObj: PullRef
thread: PullCommentThread
onChanged: () => void
onOpenFile: (path: string) => void
}) {
const { t } = useT()
const [replying, setReplying] = useState(false)
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const root = thread.root
const inline = Boolean(root.path)
async function reply() {
if (!draft.trim()) return
setBusy(true)
try {
await createComment(refObj, { body: draft.trim(), parent_comment_id: root.id })
setDraft('')
setReplying(false)
onChanged()
} finally {
setBusy(false)
}
}
async function toggleResolve() {
try {
if (root.resolved) await unresolveComment(refObj, root.id)
else await resolveComment(refObj, root.id)
onChanged()
} catch {
/* surfaced on next refresh */
}
}
return (
<div className="rounded-lg border border-shell-hairline bg-shell-surface px-3 py-2.5">
<div className="mb-1.5 flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
<MessageSquare className="h-3.5 w-3.5" aria-hidden />
<span className="font-medium text-shell-ink">{inline ? t('pull.conv.inline') : t('pull.conv.thread')}</span>
{inline ? (
<button
type="button"
onClick={() => onOpenFile(root.path ?? '')}
className="rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted hover:text-shell-ink"
>
{root.path}
{root.line != null ? `:${root.line}` : ''}
</button>
) : null}
{root.commit_sha ? <Copyable text={root.commit_sha} /> : null}
{inline ? (
<button
type="button"
onClick={() => void toggleResolve()}
className={`ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${root.resolved ? TONE.green : TONE.gray} hover:brightness-95`}
>
{root.resolved ? (
<>
<CheckCheck className="h-3.5 w-3.5" aria-hidden />
{t('pull.conv.resolved')}
</>
) : (
<>
<Check className="h-3.5 w-3.5" aria-hidden />
{t('pull.conv.markResolved')}
</>
)}
</button>
) : null}
</div>
<div className="space-y-2">
<CommentBubble refObj={refObj} comment={root} onChanged={onChanged} />
{thread.replies.map((replyComment) => (
<CommentBubble key={replyComment.id} refObj={refObj} comment={replyComment} onChanged={onChanged} />
))}
</div>
{replying ? (
<div className="mt-1.5 flex gap-2">
<textarea
autoFocus
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={2}
placeholder={t('pull.conv.replyPlaceholder')}
className="min-w-0 flex-1 resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-2.5 py-1.5 text-[13px] outline-none focus:border-shell-ink"
/>
<div className="flex flex-col gap-1.5">
<Button className="h-8 gap-1 text-[12.5px]" disabled={busy} onClick={() => void reply()}>
<Send className="h-3.5 w-3.5" aria-hidden />
{t('pull.conv.send')}
</Button>
<Button variant="ghost" className="h-6 text-[12px]" onClick={() => setReplying(false)}>
{t('common.action.cancel')}
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setReplying(true)}
className="mt-1.5 inline-flex items-center gap-1 text-[12px] text-shell-ink-muted hover:text-shell-ink"
>
<Reply className="h-3.5 w-3.5" aria-hidden />
{t('pull.conv.reply')}
</button>
)}
</div>
)
}
/* --------------------------------------------------------------- reviews */
function ReviewCard({ refObj, review, onChanged }: { refObj: PullRef; review: PullReview; onChanged: () => void }) {
const { t, tc } = useT()
const [busy, setBusy] = useState(false)
const [full, setFull] = useState<PullReview | null>(null)
const shown = full ?? review
const tone = TONE[REVIEW_STATE_TONE[shown.state]]
async function publish(state: ReviewIntent) {
setBusy(true)
try {
await publishReview(refObj, review.id, { state, body: review.content ?? null })
onChanged()
} finally {
setBusy(false)
}
}
async function dismiss() {
setBusy(true)
try {
await dismissReview(refObj, review.id)
onChanged()
} finally {
setBusy(false)
}
}
return (
<div className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
<div className="flex flex-wrap items-center gap-2 text-[12px]">
<Avatar user={review.user} size={24} />
<span className="text-[13px] font-semibold">{userName(review.user)}</span>
<span className={`rounded-full px-2 py-0.5 text-[11.5px] font-medium ${tone}`}>
{REVIEW_STATE_LABEL[review.state]}
</span>
{review.official ? (
<span className="rounded bg-shell-active px-1.5 py-0.5 text-[11px] text-shell-ink-muted">
{t('pull.conv.official')}
</span>
) : null}
<span className="text-shell-ink-muted" title={fmtTime(review.created_at)}>
{fmtRelative(review.created_at)}
</span>
{review.commit_sha ? <Copyable text={review.commit_sha} /> : null}
{review.comment_count > 0 ? (
<span className="text-shell-ink-muted">{tc('pull.conv.inlineCount', review.comment_count)}</span>
) : null}
<span className="ml-auto flex items-center gap-1.5">
{review.state === 'pending' ? (
<>
<Button
variant="secondary"
className="h-7 text-[12px]"
disabled={busy}
onClick={() => void publish('approve')}
>
{t('pull.conv.publishApprove')}
</Button>
<Button
variant="secondary"
className="h-7 text-[12px]"
disabled={busy}
onClick={() => void publish('request_changes')}
>
{t('pull.conv.publishRequestChanges')}
</Button>
<Button
variant="ghost"
className="h-7 text-[12px]"
disabled={busy}
onClick={() => void publish('comment')}
>
{t('pull.conv.publishComment')}
</Button>
</>
) : review.state === 'approved' || review.state === 'changes_requested' ? (
<button
type="button"
onClick={() => void dismiss()}
className="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[12px] text-shell-ink-muted hover:text-shell-ink"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
{t('pull.conv.dismiss')}
</button>
) : null}
</span>
</div>
{shown.content ? <Markdown className="mt-2">{shown.content}</Markdown> : null}
{!full && review.comment_count > 0 ? (
<button
type="button"
onClick={() =>
void getReview(refObj, review.id)
.then(setFull)
.catch(() => setFull(review))
}
className="mt-1.5 text-[12px] text-shell-ink-muted underline underline-offset-2 hover:text-shell-ink"
>
{t('pull.conv.loadDetails', { count: review.comment_count })}
</button>
) : null}
</div>
)
}
/* ---------------------------------------------------------------- events */
function EventRow({ event }: { event: PullEvent }) {
return (
<div className="flex flex-wrap items-center gap-2 px-1 text-[12.5px] text-shell-ink-muted">
<Avatar user={event.actor} size={18} />
<span>
<span className="font-medium text-shell-ink">{userName(event.actor)}</span> {eventLabel(event.event_type)}
</span>
{event.commit_sha ? <Copyable text={event.commit_sha} /> : null}
<span title={fmtTime(event.created_at)}>{fmtRelative(event.created_at)}</span>
</div>
)
}
/* --------------------------------------------------------------- composer */
type ReviewMode = Exclude<ReviewIntent, 'pending'> | 'comment'
const REVIEW_MODE_KEYS: Record<ReviewMode, MessageKey> = {
comment: 'pull.conv.mode.comment',
approve: 'pull.conv.mode.approve',
request_changes: 'pull.conv.mode.requestChanges',
}
const REVIEW_MODES: ReviewMode[] = ['comment', 'approve', 'request_changes']
function Composer({ refObj, pull, onPosted }: { refObj: PullRef; pull: Pull; onPosted: () => void }) {
const { t } = useT()
const [mode, setMode] = useState<ReviewIntent | 'comment'>('comment')
const [body, setBody] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function post() {
if (!body.trim()) {
setError(t('pull.conv.empty'))
return
}
setBusy(true)
setError(null)
try {
if (mode === 'comment') await createComment(refObj, { body: body.trim() })
else await submitReview(refObj, { state: mode, body: body.trim() })
setBody('')
onPosted()
} catch (failure) {
setError(failure instanceof Error ? failure.message : t('pull.conv.submitFailed'))
} finally {
setBusy(false)
}
}
const closed = pull.state !== 'open'
return (
<SideSection
title={closed ? t('pull.conv.append') : t('pull.conv.write')}
action={
<div className="flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
{REVIEW_MODES.map((choice) => (
<button
key={choice}
type="button"
aria-pressed={mode === choice}
disabled={choice !== 'comment' && (pull.is_draft || closed)}
onClick={() => setMode(choice)}
className={`rounded px-2 py-0.5 text-[12px] transition-colors disabled:opacity-40 ${
mode === choice
? 'bg-shell-active font-semibold text-shell-ink'
: 'text-shell-ink-muted hover:text-shell-ink'
}`}
>
{t(REVIEW_MODE_KEYS[choice])}
</button>
))}
</div>
}
>
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={4}
placeholder={mode === 'comment' ? t('pull.conv.commentPlaceholder') : t('pull.conv.reviewPlaceholder')}
className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13px] leading-6 outline-none focus:border-shell-ink"
/>
{mode !== 'comment' && pull.is_draft ? (
<p className="mt-1 text-[12px] text-[#8a6100]">{t('pull.conv.draftReviewHint')}</p>
) : null}
{error ? <p className="mt-1 text-[12px] text-[#b42318]">{error}</p> : null}
<div className="mt-2 flex items-center gap-2">
<Button className="h-8 gap-1 text-[12.5px]" disabled={busy} onClick={() => void post()}>
<Send className="h-3.5 w-3.5" aria-hidden />
{busy ? t('pull.conv.submitting') : mode === 'comment' ? t('pull.conv.post') : t('pull.conv.submitReview')}
</Button>
{body ? (
<Button variant="ghost" className="h-8 text-[12px]" onClick={() => setBody('')}>
{t('pull.conv.clear')}
</Button>
) : null}
</div>
</SideSection>
)
}