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
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { buildForgeProvider } from '@/lib/forge/adapters';
|
||||
import { parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues';
|
||||
|
||||
/**
|
||||
* Live state of a linked issue/PR, fetched through the forge facade.
|
||||
*
|
||||
* The linked-issues snapshot stores title and identity only — state, draft
|
||||
* and the freshest title belong to the forge. This module resolves them on
|
||||
* demand for the work-status cards, mirroring the `gitlabMrStatus.ts` pattern:
|
||||
* a module-level TTL cache shared across every surface that mounts the same
|
||||
* entity, a synchronous cache read for the initial render (so an
|
||||
* already-resolved entity never flashes back to the stale snapshot), and
|
||||
* `null` for "no authoritative live data" — never a guessed value.
|
||||
*
|
||||
* Fetches are mount-driven and refresh-driven only; nothing here polls.
|
||||
*/
|
||||
|
||||
export type LinkedEntityLive = {
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
title: string;
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
const liveCache = new Map<string, { at: number; result: LinkedEntityLive | null }>();
|
||||
|
||||
const cacheKeyFor = (id: string): string => id;
|
||||
|
||||
const readCachedLive = (id: string): LinkedEntityLive | null | undefined =>
|
||||
liveCache.get(cacheKeyFor(id))?.result;
|
||||
|
||||
/** Drop the cached live state for one linked entity, e.g. after it was rewritten. */
|
||||
export const linkedEntityLiveInvalidate = (id: string): void => {
|
||||
liveCache.delete(cacheKeyFor(id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the live state of one linked entity, or null when it cannot be
|
||||
* resolved: an unparseable id, a runtime without the provider's API, a fetch
|
||||
* that fails, or an entity the forge no longer knows.
|
||||
*
|
||||
* The fetch is addressed to `directory` and lets the provider resolve the
|
||||
* repository from the session's remotes, exactly like the facade's other
|
||||
* directory-addressed calls. Cross-repo entities therefore resolve only when
|
||||
* the session's repo is the entity's repo; anything else reports null
|
||||
* ("live unavailable") rather than guessing.
|
||||
*
|
||||
* Pull-request context is heavier than a status card needs — the facade's
|
||||
* only PR lookup fetches comments/files/diff too — but it is the single
|
||||
* facade path available, the wire call is cached server-side, and the
|
||||
* snapshot row survives regardless, so the extra weight is acceptable.
|
||||
*/
|
||||
export const resolveLinkedEntityLive = async (
|
||||
entry: LinkedIssue,
|
||||
directory: string,
|
||||
): Promise<LinkedEntityLive | null> => {
|
||||
const ref = parseLinkedIssueRef(entry);
|
||||
if (!ref || !directory) return null;
|
||||
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
const provider = apis ? buildForgeProvider(entry.provider ?? ref.provider, apis) : null;
|
||||
if (!provider) return null;
|
||||
|
||||
const key = cacheKeyFor(entry.id);
|
||||
const cached = liveCache.get(key);
|
||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
let result: LinkedEntityLive | null = null;
|
||||
try {
|
||||
if (entry.kind === 'pull') {
|
||||
const context = await provider.getPullRequestContext(directory, ref.number);
|
||||
const pr = context.pr;
|
||||
if (pr) {
|
||||
result = { state: pr.state, draft: pr.draft, title: pr.title, fetchedAt: Date.now() };
|
||||
}
|
||||
} else {
|
||||
const detail = await provider.getIssue(directory, ref.number);
|
||||
const issue = detail.issue;
|
||||
if (issue) {
|
||||
result = { state: issue.state, draft: false, title: issue.title, fetchedAt: Date.now() };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
liveCache.set(key, { at: Date.now(), result });
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to one linked entity's live state. Reads the TTL cache
|
||||
* synchronously for the initial render; a cache miss shows the loading state
|
||||
* rather than a result from a previous fetch. `unavailable` means a fetch ran
|
||||
* and resolved to nothing — never a not-yet-fetched state.
|
||||
*
|
||||
* `refresh` invalidates the cache and refetches this one entry.
|
||||
*/
|
||||
export const useLinkedEntityLive = (
|
||||
entry: LinkedIssue,
|
||||
directory: string | null | undefined,
|
||||
): { live: LinkedEntityLive | null; loading: boolean; unavailable: boolean; refresh: () => void } => {
|
||||
const [live, setLive] = useState<LinkedEntityLive | null>(() =>
|
||||
directory ? (readCachedLive(entry.id) ?? null) : null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Whether the first fetch for this entity has settled — the difference
|
||||
// between "not fetched yet" and "fetched and found nothing". Without it a
|
||||
// cache-miss mount would report `unavailable` for one frame before the
|
||||
// effect marks the fetch as loading.
|
||||
const [settled, setSettled] = useState<boolean>(() =>
|
||||
directory ? readCachedLive(entry.id) !== undefined : false,
|
||||
);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!directory) {
|
||||
setLive(null);
|
||||
setLoading(false);
|
||||
setSettled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
const cached = readCachedLive(entry.id);
|
||||
const cacheEntry = liveCache.get(cacheKeyFor(entry.id));
|
||||
const fresh = cacheEntry !== undefined && Date.now() - cacheEntry.at < CACHE_TTL_MS;
|
||||
|
||||
if (fresh) {
|
||||
setLive(cached ?? null);
|
||||
setLoading(false);
|
||||
setSettled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// A stale entry stays on screen while it refreshes; a missing one shows
|
||||
// the loading state rather than a result from a previous entity.
|
||||
setLive(cached ?? null);
|
||||
setLoading(true);
|
||||
setSettled(false);
|
||||
|
||||
void resolveLinkedEntityLive(entry, directory).then((resolved) => {
|
||||
if (mounted) {
|
||||
setLive(resolved);
|
||||
setLoading(false);
|
||||
setSettled(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [entry, directory, tick]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
linkedEntityLiveInvalidate(entry.id);
|
||||
setTick((current) => current + 1);
|
||||
}, [entry.id]);
|
||||
|
||||
return { live, loading, unavailable: settled && !loading && live === null, refresh };
|
||||
};
|
||||
Reference in New Issue
Block a user