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:
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user