feat(npcs): show multiple character overlays in session playback

Toggle NPCs independently from the characters window, keep one shared dim/close-all, and scroll descriptions for all selected characters.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-21 13:23:19 +08:00
parent f4c0ac1438
commit 1829191410
8 changed files with 538 additions and 121 deletions
+65 -66
View File
@@ -52,13 +52,11 @@ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
function RuntimeNpcTile({
npc,
selected,
active,
accentColor,
onActivate,
}: {
npc: ProjectNpc;
selected: boolean;
active: boolean;
accentColor?: string | null;
onActivate: () => void;
}) {
@@ -66,9 +64,7 @@ function RuntimeNpcTile({
return (
<button
type="button"
className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : '']
.filter(Boolean)
.join(' ')}
className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
onClick={onActivate}
>
@@ -85,16 +81,14 @@ function RuntimeGroupSection({
depth,
isExpanded,
onToggleExpanded,
selectedId,
activeId,
selectedIds,
onActivate,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedId: NpcId | null;
activeId: NpcId | null;
selectedIds: ReadonlySet<NpcId>;
onActivate: (id: NpcId) => void;
}) {
const g = node.group;
@@ -120,8 +114,7 @@ function RuntimeGroupSection({
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
active={n.id === activeId}
selected={selectedIds.has(n.id)}
accentColor={g.color}
onActivate={() => onActivate(n.id)}
/>
@@ -133,8 +126,7 @@ function RuntimeGroupSection({
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedId={selectedId}
activeId={activeId}
selectedIds={selectedIds}
onActivate={onActivate}
/>
))}
@@ -149,29 +141,30 @@ export function NpcsApp() {
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [overlay, overlayApi] = useNpcsOverlayState();
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
const list = project?.npcs ?? [];
setSelectedId(list[0]?.id ?? null);
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
const activeIds = overlay?.activeNpcIds ?? [];
const hasActive = activeIds.length > 0;
const zoomTool = overlay?.zoomTool ?? null;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (overlay?.activeNpcId) {
if (hasActive) {
void overlayApi.dispatch({ kind: 'hide' });
return;
}
if (overlay?.zoomTool) {
if (zoomTool) {
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
return;
}
@@ -180,16 +173,16 @@ export function NpcsApp() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
}, [hasActive, zoomTool, overlayApi]);
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
const activeId = overlay?.activeNpcId ?? null;
const zoomTool = overlay?.zoomTool ?? null;
const effectiveSelectedId =
selectedId && npcs.some((n) => n.id === selectedId) ? selectedId : (npcs[0]?.id ?? null);
const selected = npcs.find((n) => n.id === effectiveSelectedId) ?? null;
const selectedIds = useMemo(() => new Set(activeIds), [activeIds]);
const selectedNpcs = useMemo(
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
[activeIds, npcs],
);
const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -229,26 +222,27 @@ export function NpcsApp() {
[searching],
);
const relationsForSelected = useMemo(() => {
if (!selected) return [];
return relations
.filter((r) => r.sourceNpcId === selected.id)
.map((r) => {
const other = npcs.find((n) => n.id === r.targetNpcId);
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
});
}, [npcs, relations, selected]);
const relationsByNpcId = useMemo(() => {
const map = new Map<NpcId, { id: string; text: string }[]>();
for (const npc of selectedNpcs) {
const list = relations
.filter((r) => r.sourceNpcId === npc.id)
.map((r) => {
const other = npcs.find((n) => n.id === r.targetNpcId);
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
});
map.set(npc.id, list);
}
return map;
}, [npcs, relations, selectedNpcs]);
const onSelectTile = useCallback(
(id: NpcId) => {
setSelectedId(id);
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
},
[overlayApi],
);
const safeHtml = selected ? sanitizeSceneDescriptionHtml(selected.description) : '';
return (
<div className={styles.page}>
<div className={styles.toolbar}>
@@ -283,7 +277,7 @@ export function NpcsApp() {
>
<ZoomOutIcon />
</Button>
{activeId ? (
{hasActive ? (
<Button
title={t('npcs.closeOverlay')}
ariaLabel={t('npcs.closeOverlay')}
@@ -307,32 +301,40 @@ export function NpcsApp() {
<div className={styles.body}>
<div className={styles.detail}>
{selected ? (
<>
<div className={styles.detailName}>{selected.name}</div>
{safeHtml ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
</div>
) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
)}
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}>
{relationsForSelected.map((r) => (
<div key={r.id} className={styles.relationItem}>
{r.text}
{selectedNpcs.length > 0 ? (
selectedNpcs.map((npc) => {
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
return (
<div key={npc.id} className={styles.detailCard}>
<div className={styles.detailName}>{npc.name}</div>
{safeHtml ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
</div>
) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
)}
{npcRelations.length > 0 ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}>
{npcRelations.map((r) => (
<div key={r.id} className={styles.relationItem}>
{r.text}
</div>
))}
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
</>
);
})
) : (
<div className={styles.detailEmpty}>{t('npcs.windowEmpty')}</div>
<div className={styles.detailEmpty}>
{npcs.length === 0 ? t('npcs.windowEmpty') : t('npcs.selectToShow')}
</div>
)}
</div>
@@ -344,8 +346,7 @@ export function NpcsApp() {
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === effectiveSelectedId}
active={n.id === activeId}
selected={selectedIds.has(n.id)}
onActivate={() => onSelectTile(n.id)}
/>
))
@@ -358,8 +359,7 @@ export function NpcsApp() {
depth={0}
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedId={effectiveSelectedId}
activeId={activeId}
selectedIds={selectedIds}
onActivate={onSelectTile}
/>
))}
@@ -371,8 +371,7 @@ export function NpcsApp() {
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === effectiveSelectedId}
active={n.id === activeId}
selected={selectedIds.has(n.id)}
onActivate={() => onSelectTile(n.id)}
/>
))}