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:
2026-08-16 16:29:25 +00:00
parent 8f5cfdcd62
commit 1f28b61c5a
22 changed files with 2010 additions and 48 deletions
@@ -269,6 +269,10 @@ Rows that name something the app can already show are buttons:
| MCP status | the state doubles as the button that reconnects |
| Pinned (pin icon) | unpins the message |
| Pinned (text) | jumps the transcript to that message |
| Linked (title) | opens the issue/PR in the browser |
| Linked (refresh) | refetches that entity's live state (cache-busting) |
| Linked (unlink) | removes the link from the session, after confirm |
| Link (section header) | opens the paste-a-URL link dialog |
The goal icon reproduces the **composer target button's** colour mapping, not
the goal strip's. The two disagree today — the strip paints `paused` muted and
@@ -296,18 +300,31 @@ something other than "tools available".
### Linked issues and pull requests
Written by the flows that already attach a thread — the composer's issue/PR
pickers, and session creation from an issue or PR in `NewWorktreeDialog` and
`GitHubIssuePickerDialog`. There is no manual "link this" control: attaching a
thread to the work *is* the act of linking it.
Written by the flows that attach a thread — the composer's issue/PR pickers,
session creation from an issue or PR in `NewWorktreeDialog` and
`GitHubIssuePickerDialog`**and** by the section's own Link control, which
accepts a pasted issue/PR URL (validated against the forge before recording)
and per-row Unlink. Attaching a thread and pasting a URL are the same act of
linking; the row always knows how to unlink itself.
Stored in session metadata as a **snapshot** (`lib/linkedIssues.ts`, namespace
`openchamber.linked_issues`), riding the same `patchSessionMetadata` channel as
pinned messages. Number, title, url, author and avatar only — the body,
comments and state belong to GitHub, and mirroring them would mean owning their
staleness. The stored title can drift; that is the price of a store that never
needs refreshing. The row opens the real thread, which is where current state
lives.
comments and state belong to the forge, and mirroring them would mean owning
their staleness.
Each row renders as a **live card** (`lib/linkedEntityLive.ts`) when the entry
resolves to a forge entity and the runtime carries that provider's API: the
current open/merged/closed state, the draft marker and the freshest title are
fetched on mount and on the row's refresh button — never on an interval. The
snapshot stays the fallback for whatever the fetch has not answered yet
(initial loading shows a spinner; a failed fetch shows a muted "live
unavailable" marker rather than silently looking stale). Fetches go through the
forge facade (`lib/forge/adapters.ts`) addressed to the session's directory, so
the repo resolves from the session's remotes; a cross-repo entity reports live
state as unavailable instead of guessing. Results are cached per entity for
60s in a module-level TTL cache, read synchronously for the initial render so
an already-resolved entity never flashes back to the snapshot.
Writes happen **after** the send promise resolves and are deliberately
swallowed on failure: the message went out, and a missing bookkeeping entry
@@ -1,21 +1,167 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useSession } from '@/sync/sync-context';
import { getLinkedIssues } from '@/lib/linkedIssues';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues';
import { linkedEntityLiveInvalidate, useLinkedEntityLive, type LinkedEntityLive } from '@/lib/linkedEntityLive';
import { setLinkedIssue } from '@/sync/session-actions';
import { WorkStatusCollapsibleSection, WorkStatusPill, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import { WorkStatusLinkDialog } from './WorkStatusLinkDialog';
type Props = {
sessionId: string | null;
directory: string | null;
};
const STATE_COLOR: Record<LinkedEntityLive['state'], string> = {
open: 'var(--pr-open)',
closed: 'var(--pr-closed)',
merged: 'var(--pr-merged)',
};
const STATE_LABEL_KEY: Record<LinkedEntityLive['state'], I18nKey> = {
open: 'forge.state.open',
closed: 'forge.state.closed',
merged: 'forge.state.merged',
};
/**
* What is loaded into the agent's context: the GitHub threads this session was
* pointed at, plus how much ambient material is available.
* One linked issue/PR as a live card.
*
* When the entry resolves to a forge entity and the runtime carries the
* provider's API, the row fetches current state (open/merged/closed, draft,
* freshest title) on mount and on demand — never on an interval. The snapshot
* stays the fallback for everything the live fetch has not answered yet:
* loading keeps the snapshot row with a spinner, a failed fetch keeps it with
* a muted "live unavailable" marker instead of silently looking stale.
*/
const LinkedIssueRow: React.FC<{
entry: LinkedIssue;
sessionId: string | null;
directory: string | null | undefined;
}> = ({ entry, sessionId, directory }) => {
const { t } = useI18n();
const ref = React.useMemo(() => parseLinkedIssueRef(entry), [entry]);
const providerKind = entry.provider ?? ref?.provider ?? null;
const apis = getRegisteredRuntimeAPIs();
const canLive = Boolean(
directory
&& ref
&& ((providerKind === 'github' && apis?.github)
|| (providerKind === 'gitlab' && apis?.gitlab)
|| (providerKind === 'gitea' && apis?.gitea)),
);
const { live, loading, unavailable, refresh } = useLinkedEntityLive(entry, canLive ? directory : null);
const [unlinking, setUnlinking] = React.useState(false);
const handleUnlink = React.useCallback(async () => {
if (!sessionId || !directory || unlinking) return;
if (!window.confirm(t('chat.workStatus.linkedIssues.unlinkConfirm'))) return;
setUnlinking(true);
try {
await setLinkedIssue(sessionId, directory, entry, false);
linkedEntityLiveInvalidate(entry.id);
} catch {
toast.error(t('chat.workStatus.linkedIssues.unlinkFailed'));
} finally {
setUnlinking(false);
}
}, [directory, entry, sessionId, t, unlinking]);
const openInBrowser = React.useCallback(() => {
if (typeof window !== 'undefined') {
window.open(entry.url, '_blank', 'noopener,noreferrer');
}
}, [entry.url]);
const stateLabel = live ? t(STATE_LABEL_KEY[live.state]) : null;
// The live fetch is the freshest word on the title; the snapshot covers
// everything the fetch has not answered yet (initial loading, failure).
const title = live?.title ?? entry.title;
const leading = entry.authorAvatarUrl ? (
<img src={entry.authorAvatarUrl} alt="" className="size-4 shrink-0 rounded-full" loading="lazy" />
) : (
<Icon
name={entry.kind === 'pull' ? 'git-pull-request' : 'error-warning'}
className="size-4 shrink-0 text-muted-foreground"
/>
);
// A plain row, not WorkStatusRow: the card carries its own controls
// (refresh, unlink) next to the number, which a full-row button cannot
// contain without nesting buttons.
return (
<div className="flex h-7 w-full items-center gap-2 rounded-md px-1 text-left">
{leading}
<button
type="button"
onClick={openInBrowser}
aria-label={t('chat.workStatus.linkedIssues.open', { number: entry.number })}
className="flex min-w-0 flex-1 items-center gap-1.5 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
>
<span className="truncate">{title}</span>
{canLive && unavailable ? (
<span className="shrink-0 text-[11px] text-muted-foreground">
{t('chat.workStatus.linkedIssues.liveUnavailable')}
</span>
) : null}
{canLive && loading ? <Icon name="loader-4" className="size-3 shrink-0 animate-spin text-muted-foreground" /> : null}
</button>
<span className="flex shrink-0 items-center gap-1.5 text-[13px] tabular-nums">
{live ? (
<span
role="img"
aria-label={stateLabel ?? undefined}
title={stateLabel ?? undefined}
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: STATE_COLOR[live.state] }}
/>
) : null}
{live?.draft ? <WorkStatusPill>{t('chat.workStatus.pr.draft')}</WorkStatusPill> : null}
<WorkStatusValue tone="muted">{`#${entry.number}`}</WorkStatusValue>
{canLive ? (
<button
type="button"
aria-label={t('chat.workStatus.linkedIssues.liveRefresh')}
title={t('chat.workStatus.linkedIssues.liveRefresh')}
disabled={loading}
onClick={refresh}
className="rounded p-0.5 text-muted-foreground transition-opacity hover:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
>
<Icon name="refresh" className="size-3.5" />
</button>
) : null}
<button
type="button"
aria-label={t('chat.workStatus.linkedIssues.unlink')}
title={t('chat.workStatus.linkedIssues.unlink')}
disabled={unlinking}
onClick={handleUnlink}
className={cn(
'rounded p-0.5 text-muted-foreground transition-colors',
'hover:text-[var(--status-error)] disabled:cursor-not-allowed disabled:opacity-40',
)}
>
<Icon name={unlinking ? 'loader-4' : 'delete-bin'} className={cn('size-3.5', unlinking && 'animate-spin')} />
</button>
</span>
</div>
);
};
/**
* What is loaded into the agent's context: the git-forge threads this session
* was pointed at (live state cards plus link/unlink controls), and how much
* ambient material is available.
*
* Agents are deliberately absent — an agent is who does the work, not material
* the work is done with. Tools are absent for want of an honest source:
@@ -24,12 +170,16 @@ type Props = {
*/
export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const [linkDialogOpen, setLinkDialogOpen] = React.useState(false);
const session = useSession(sessionId ?? '', directory ?? undefined);
const skills = useSkillsStore((state) => state.skills);
const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
);
// The session's server-confirmed directory is the authoritative address for
// forge lookups; the prop only covers drafts with no session yet.
const sessionDirectory = session?.directory ?? directory;
// Skills were previously fetched only when the composer's slash autocomplete
// opened, so this row reported whatever count happened to be cached — often
@@ -85,36 +235,43 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
}
}
const hasSessionContext = Boolean(sessionId && sessionDirectory);
return (
<WorkStatusCollapsibleSection
id="context-sources"
title={t('chat.workStatus.section.contextBreakdown')}
icon="stack"
summary={summaryParts.join(' · ')}
action={(
<Button
size="xs"
variant="ghost"
disabled={!hasSessionContext}
onClick={() => setLinkDialogOpen(true)}
aria-label={t('chat.workStatus.linkedIssues.link')}
title={t('chat.workStatus.linkedIssues.link')}
>
<Icon name="add" className="size-3.5" />
<span>{t('chat.workStatus.linkedIssues.link')}</span>
</Button>
)}
>
{/* Attached threads first: they are specific to this session, while the
counts below describe the workspace. */}
{linked.map((entry) => (
<WorkStatusRow
<LinkedIssueRow
key={entry.id}
leading={entry.authorAvatarUrl ? (
<img src={entry.authorAvatarUrl} alt="" className="size-4 shrink-0 rounded-full" loading="lazy" />
) : (
<Icon
name={entry.kind === 'pull' ? 'git-pull-request' : 'error-warning'}
className="size-4 shrink-0 text-muted-foreground"
/>
)}
label={entry.title}
muted
// The stored snapshot is enough to render; the live thread only ever
// exists on github.com.
onClick={() => window.open(entry.url, '_blank', 'noopener,noreferrer')}
ariaLabel={t('chat.workStatus.linkedIssues.open', { number: entry.number })}
value={<WorkStatusValue tone="muted">{`#${entry.number}`}</WorkStatusValue>}
entry={entry}
sessionId={sessionId}
directory={sessionDirectory}
/>
))}
{linked.length === 0 ? (
<WorkStatusRow muted label={t('chat.workStatus.linkedIssues.empty')} />
) : null}
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.skills')}
@@ -125,6 +282,13 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
label={t('chat.workStatus.breakdown.mcp')}
value={<WorkStatusValue>{mcpCount}</WorkStatusValue>}
/>
<WorkStatusLinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
sessionId={sessionId}
directory={sessionDirectory}
/>
</WorkStatusCollapsibleSection>
);
};
@@ -0,0 +1,146 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { buildForgeProvider } from '@/lib/forge/adapters';
import { useI18n } from '@/lib/i18n';
import { buildLinkedIssue, parseForgeEntityUrl } from '@/lib/linkedIssues';
import { setLinkedIssue } from '@/sync/session-actions';
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: string | null;
directory: string | null;
};
/**
* Manual "link this issue/PR" control for the context-sources section.
*
* The URL is parsed first (`parseForgeEntityUrl`) and validated against the
* forge before the link is recorded: the live fetch proves the entity exists
* and supplies its real title, so a stale or mistyped URL surfaces as a
* `linkFailed` toast instead of a snapshot row that never resolves. Linking
* rides the same `setLinkedIssue`/session-metadata channel as the attach
* flows, so the row appears through the section's existing session read.
*/
export const WorkStatusLinkDialog: React.FC<Props> = ({ open, onOpenChange, sessionId, directory }) => {
const { t } = useI18n();
const [url, setUrl] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const handleOpenChange = React.useCallback((next: boolean) => {
if (!next) {
setUrl('');
setError(null);
setBusy(false);
}
onOpenChange(next);
}, [onOpenChange]);
const handleLink = React.useCallback(async () => {
if (!sessionId || !directory || busy) return;
const parsed = parseForgeEntityUrl(url);
if (!parsed) {
setError(t('chat.workStatus.linkedIssues.linkInvalid'));
return;
}
setBusy(true);
setError(null);
try {
const apis = getRegisteredRuntimeAPIs();
const provider = apis ? buildForgeProvider(parsed.provider, apis) : null;
if (!provider) {
toast.error(t('chat.workStatus.linkedIssues.linkFailed'));
return;
}
// Validate the entity exists on the forge and grab its real title. The
// facade resolves the repo from the session's remotes; an entity the
// forge no longer knows (or a repo the session cannot reach) reports no
// title and the link is refused.
let title: string | null = null;
try {
if (parsed.kind === 'pull') {
const context = await provider.getPullRequestContext(directory, parsed.number);
title = context.pr?.title ?? null;
} else {
const detail = await provider.getIssue(directory, parsed.number);
title = detail.issue?.title ?? null;
}
} catch {
title = null;
}
if (!title) {
toast.error(t('chat.workStatus.linkedIssues.linkFailed'));
return;
}
const issue = buildLinkedIssue({
url: url.trim(),
number: parsed.number,
title,
kind: parsed.kind,
provider: parsed.provider,
repo: parsed.repo,
linkedAt: Date.now(),
});
await setLinkedIssue(sessionId, directory, issue, true);
toast.success(t('chat.workStatus.linkedIssues.linked'));
handleOpenChange(false);
} catch {
toast.error(t('chat.workStatus.linkedIssues.linkFailed'));
} finally {
setBusy(false);
}
}, [busy, directory, handleOpenChange, sessionId, t, url]);
const canSubmit = Boolean(sessionId && directory) && !busy && url.trim().length > 0;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('chat.workStatus.linkedIssues.linkDialogTitle')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<Input
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder={t('chat.workStatus.linkedIssues.linkPlaceholder')}
aria-invalid={error ? true : undefined}
disabled={busy || !sessionId || !directory}
autoFocus
onKeyDown={(event) => {
if (event.key === 'Enter' && canSubmit) void handleLink();
}}
/>
{error ? <p className="text-xs text-[var(--status-error)]">{error}</p> : null}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => handleOpenChange(false)} disabled={busy}>
{t('settings.common.actions.cancel')}
</Button>
<Button onClick={() => void handleLink()} disabled={!canSubmit}>
{busy ? <Icon name="loader-4" className="size-4 animate-spin" /> : null}
{t('chat.workStatus.linkedIssues.link')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,9 +1,15 @@
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,
@@ -19,6 +25,7 @@ import { ForgeCommitsSection } from './ForgeCommitsSection';
import { ForgeFilesDiffSection } from './ForgeFilesDiffSection';
import { ForgeTimelineSection } from './ForgeTimelineSection';
import { ForgeChecksSection } from './ForgeChecksSection';
import { LinkedSessionsSection } from './LinkedSessionsSection';
import {
ForgeCommentComposer,
ForgeEntityActions,
@@ -113,6 +120,34 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
// 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);
}, []);
@@ -251,6 +286,7 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
</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}
@@ -314,6 +350,8 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
<ForgeMetadataChips kind="pull" pr={pr} />
<LinkedSessionsSection sessions={linkedSessions} onOpenSession={openSession} />
{checksForPull ? (
<section aria-label={t('forge.section.checks')}>
<SectionTitle>{t('forge.section.checks')}</SectionTitle>
@@ -0,0 +1,65 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
import type { LinkedSessionRow } from '@/lib/linkedSessionMatches';
interface LinkedSessionsSectionProps {
sessions: LinkedSessionRow[];
/** Called with the session id when a row is clicked to open its chat. */
onOpenSession: (sessionId: string) => void;
}
/**
* "Chats working on this" — sessions in the current project that have this
* forge entity linked (`metadata.openchamber.linked_issues`). Purely derived
* from the already-loaded session list; rows open the session's chat. Renders
* nothing when there are no matches.
*/
export const LinkedSessionsSection = React.memo<LinkedSessionsSectionProps>(function LinkedSessionsSection({
sessions,
onOpenSession,
}) {
const { t } = useI18n();
if (sessions.length === 0) {
return null;
}
return (
<section aria-label={t('forge.linkedSessions.title')}>
<div className="flex items-center gap-2 py-0.5">
<Icon name="chat-4" className="size-4 shrink-0 text-muted-foreground" />
<h4 className="typography-ui-label font-semibold text-foreground">{t('forge.linkedSessions.title')}</h4>
<span
aria-label={t('forge.linkedSessions.count', { count: sessions.length })}
title={t('forge.linkedSessions.count', { count: sessions.length })}
className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-interactive-hover px-1.5 typography-micro text-foreground"
>
{sessions.length}
</span>
</div>
<ul className="mt-1 flex flex-col gap-0.5">
{sessions.map((session) => (
<li key={session.sessionId}>
<button
type="button"
onClick={() => onOpenSession(session.sessionId)}
className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-1 py-1.5 text-left transition-colors hover:bg-interactive-hover/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('forge.linkedSessions.open', { title: session.title })}
title={session.title}
>
<Icon name="chat-4" className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate typography-small text-foreground">{session.title}</span>
{typeof session.linkedAt === 'number' ? (
<span className="shrink-0 typography-micro text-muted-foreground">
{formatSessionCompactDateLabel(session.linkedAt)}
</span>
) : null}
</button>
</li>
))}
</ul>
</section>
);
});