Files
openchamber/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx
T
bot-hermes 1f28b61c5a feat(ui): chat-entity linking — live linked-entity cards, manual link/unlink, session cross-listing
- linkedIssues: provider/repo/host fields (backward-compatible), gitea id generation fix, parseLinkedIssueRef/parseForgeEntityUrl
- work-status panel: live state cards (TTL-cached), link-by-URL dialog, per-row unlink
- forge entity view: 'Chats working on this' section derived from the session store
2026-08-16 16:29:25 +00:00

392 lines
15 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useI18n } from '@/lib/i18n';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { normalizePath } from '@/lib/pathNormalization';
import { useUIStore } from '@/stores/useUIStore';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { findLinkedSessionsForEntity, linkedEntityCandidateIds } from '@/lib/linkedSessionMatches';
import type {
ForgeChecksResult,
ForgeCommitsResult,
ForgeEntityRef,
ForgeIssueDetail,
ForgeProvider,
ForgePullRequestContext,
ForgeTimelineResult,
} from '@/lib/forge/provider';
import type { ForgeComment, ForgeTimelineEvent } from '@/lib/forge/types';
import { ForgeMetadataChips } from './ForgeMetadataChips';
import { ForgeCommitsSection } from './ForgeCommitsSection';
import { ForgeFilesDiffSection } from './ForgeFilesDiffSection';
import { ForgeTimelineSection } from './ForgeTimelineSection';
import { ForgeChecksSection } from './ForgeChecksSection';
import { LinkedSessionsSection } from './LinkedSessionsSection';
import {
ForgeCommentComposer,
ForgeEntityActions,
ForgeMetadataEditor,
ForgeThreadReply,
} from './actions';
interface ForgeEntityDetailViewProps {
provider: ForgeProvider;
directory: string;
number: number;
options?: {
sourceRepo?: string | null;
kind?: 'pull' | 'issue';
};
/** Optional CTA target for the not-connected notice. */
onOpenSettings?: () => void;
}
interface PullData {
context: ForgePullRequestContext | null;
commits: ForgeCommitsResult | null;
timeline: ForgeTimelineResult | null;
checks: ForgeChecksResult | null;
}
const markdownClassName =
'typography-markdown-body text-foreground break-words [&_a]:no-underline [&_a:hover]:no-underline';
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<h4 className="typography-ui-label font-semibold text-foreground">{children}</h4>
);
const LoadingBlock: React.FC<{ label: string }> = ({ label }) => (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 py-1 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{label}
</div>
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-16 w-full" />
</div>
);
const ErrorBlock: React.FC<{ message: string }> = ({ message }) => (
<div className="flex items-center gap-2 rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-3 py-2 typography-micro text-[var(--status-error)]">
<Icon name="error-warning" className="size-4 shrink-0" />
{message}
</div>
);
const NotConnectedBlock: React.FC<{ onOpenSettings?: () => void }> = ({ onOpenSettings }) => {
const { t } = useI18n();
return (
<div className="flex flex-col gap-2 rounded-md border border-border/40 bg-surface-elevated px-4 py-3">
<div className="typography-ui-label text-foreground">{t('forge.notConnected')}</div>
{onOpenSettings ? (
<Button variant="outline" size="sm" className="w-fit" onClick={onOpenSettings}>
{t('gitView.pr.actions.openSettings')}
</Button>
) : null}
</div>
);
};
/**
* Self-loading detail view for a forge pull request or issue. Owns all data
* fetching through the provider facade (context/issue plus commits, timeline,
* and checks where the provider implements them) and renders the presentational
* section components. Sections stay capability-gated: GitHub check runs ride on
* the pull-request context, Gitea statuses come from `getChecks`, GitLab has no
* checks surface.
*/
export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ provider, directory, number, options, onOpenSettings }) => {
const { t } = useI18n();
const isIssue = (options?.kind ?? 'pull') === 'issue';
const sourceRepo = options?.sourceRepo ?? null;
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);
// Sessions in the same project as this view, from the same authoritative
// store the sidebar consumes. Derived client-side: no extra fetching.
const allSessions = useGlobalSessionsStore(useShallow((state) => state.activeSessions));
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
// The repo this entity lives on, resolved from the loaded context/issue.
const repoRef = isIssue ? (issueDetail?.repo ?? null) : (pull?.context?.repo ?? null);
const projectSessions = useMemo(() => {
const base = normalizePath(directory);
if (!base) return allSessions;
return allSessions.filter((session) => {
const sessionDirectory = resolveGlobalSessionDirectory(session);
return sessionDirectory === base || (sessionDirectory !== null && sessionDirectory.startsWith(`${base}/`));
});
}, [allSessions, directory]);
const linkedSessions = useMemo(() => {
if (!repoRef) return [];
const candidateIds = linkedEntityCandidateIds(repoRef, number);
return findLinkedSessionsForEntity(projectSessions, provider.kind, candidateIds);
}, [number, projectSessions, provider.kind, repoRef]);
const openSession = useCallback((sessionId: string) => {
useUIStore.getState().closeMainSurfaces();
setCurrentSession(sessionId);
}, [setCurrentSession]);
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) {
if (!provider.getIssue) {
setIsLoading(false);
return;
}
void provider
.getIssue(directory, number, { sourceRepo })
.then((detail) => {
if (cancelled) return;
setIssueDetail(detail);
setIsLoading(false);
})
.catch(() => {
if (cancelled) return;
setIsLoading(false);
});
return () => {
cancelled = true;
};
}
const canCommits = typeof provider.getCommits === 'function';
const canTimeline = typeof provider.getTimeline === 'function';
const canChecks = provider.capabilities.checks === 'commit-statuses' && typeof provider.getChecks === 'function';
void (async () => {
const context = provider.getPullRequestContext
? await provider.getPullRequestContext(directory, number, { includeDiff: true, sourceRepo })
: null;
const [commits, timeline, checks] = await Promise.all([
canCommits ? provider.getCommits!(directory, number, { sourceRepo }) : Promise.resolve(null),
canTimeline ? provider.getTimeline!(directory, number, { sourceRepo }) : Promise.resolve(null),
canChecks ? provider.getChecks!(directory, number, { sourceRepo }) : Promise.resolve(null),
]);
if (cancelled) return;
setPull({ context, commits, timeline, checks });
setIsLoading(false);
})().catch(() => {
if (cancelled) return;
setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [directory, isIssue, number, provider, reloadToken, sourceRepo]);
const mergedComments = useMemo<ForgeComment[]>(() => {
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]);
const checksForPull = useMemo<{ kind: 'check-runs' | 'commit-statuses'; summary: ForgeChecksResult['checks'] } | null>(() => {
const context = pull?.context;
if (!context) return null;
const checksResult = pull?.checks;
if (provider.capabilities.checks === 'check-runs') {
return context.checks ? { kind: 'check-runs', summary: context.checks } : null;
}
if (provider.capabilities.checks === 'commit-statuses') {
return checksResult ? { kind: 'commit-statuses', summary: checksResult.checks } : null;
}
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')} />;
}
if (isIssue) {
if (!issueDetail || !issueDetail.connected) {
return <NotConnectedBlock onOpenSettings={onOpenSettings} />;
}
const issue = issueDetail.issue;
if (!issue) {
return <ErrorBlock message={t('forge.error')} />;
}
const issueState = issue.state === 'closed' ? 'closed' : 'open';
const stateColor = `var(--pr-${issueState})`;
return (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Icon name="sticky-note" className="size-4 shrink-0" style={{ color: stateColor }} />
<h3 className="min-w-0 truncate typography-ui-header font-semibold text-foreground">{issue.title}</h3>
<span className="typography-meta text-muted-foreground">#{issue.number}</span>
<span className="typography-micro shrink-0" style={{ color: stateColor }}>
{t(`forge.state.${issueState}`)}
</span>
</div>
<ForgeEntityActions provider={provider} directory={directory} ref={ref} issue={issue} onChanged={reload} />
<ForgeMetadataChips kind="issue" issue={issue} />
<LinkedSessionsSection sessions={linkedSessions} onOpenSession={openSession} />
<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}
<section aria-label={t('forge.section.timeline')}>
<SectionTitle>{t('forge.section.timeline')}</SectionTitle>
<ForgeTimelineSection
events={[]}
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>
);
}
if (!pull || !pull.context || !pull.context.connected) {
return <NotConnectedBlock onOpenSettings={onOpenSettings} />;
}
const context = pull.context;
const pr = context.pr;
if (!pr) {
return <ErrorBlock message={t('forge.error')} />;
}
const stateColor = `var(--pr-${pr.state})`;
const stateIcon = pr.state === 'merged'
? 'git-merge'
: pr.state === 'closed'
? 'git-close-pull-request'
: 'git-pull-request';
return (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Icon name={stateIcon} className="size-4 shrink-0" style={{ color: stateColor }} />
<h3 className="min-w-0 truncate typography-ui-header font-semibold text-foreground">{pr.title}</h3>
<span className="typography-meta text-muted-foreground">#{pr.number}</span>
<span className="typography-micro shrink-0" style={{ color: stateColor }}>
{t(`forge.state.${pr.state}` as never)}
</span>
{pr.draft ? (
<span className="inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground">
{t('forge.draft')}
</span>
) : null}
</div>
<ForgeEntityActions provider={provider} directory={directory} ref={ref} pr={pr} onChanged={reload} />
<ForgeMetadataChips kind="pull" pr={pr} />
<LinkedSessionsSection sessions={linkedSessions} onOpenSession={openSession} />
{checksForPull ? (
<section aria-label={t('forge.section.checks')}>
<SectionTitle>{t('forge.section.checks')}</SectionTitle>
<ForgeChecksSection
kind={checksForPull.kind}
summary={checksForPull.summary}
error={provider.capabilities.checks === 'commit-statuses' ? (pull.checks?.error ?? null) : null}
/>
</section>
) : null}
{typeof provider.getCommits === 'function' ? (
<section aria-label={t('forge.section.commits')}>
<SectionTitle>{t('forge.section.commits')}</SectionTitle>
<ForgeCommitsSection commits={pull.commits?.commits ?? null} error={pull.commits?.error ?? null} />
</section>
) : null}
<section aria-label={t('forge.section.files')}>
<SectionTitle>{t('forge.section.files')}</SectionTitle>
<ForgeFilesDiffSection files={context.files ?? null} diff={context.diff} />
</section>
<section aria-label={t('forge.section.timeline')}>
<SectionTitle>{t('forge.section.timeline')}</SectionTitle>
<ForgeTimelineSection
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>
);
};