V2 Phase 4: Signals engine — proactive, app-wide one-click assists

The "buttons, not chatbot" core: the app recognizes a situation and offers the
fix; the user never has to formulate a request.

- src/lib/assistant/signals.ts: buildSignals() aggregates the existing advisor
  detectors with new kernel-aware ones — quest-complete (all objectives done →
  "Mark complete"), caster out-of-slots → "Short rest", concentrating-while-
  bloodied warning — deduped and severity-sorted. 6 unit tests.
- extended SuggestAction with shortRest + completeQuest; one shared dispatcher
  (useSignalAction) used by both surfaces so behaviour can't drift.
- SignalsBell: an ambient bell + popover in the top bar, visible from every
  screen, with a count badge, one-click actions, and per-signal/clear-all
  dismiss. AssistantPage refactored onto the same buildSignals + dispatcher.
- the AI cards were already actionable (NpcGen → npcsRepo, EncounterTip → add
  to encounter, Assistant encounter builder → combat).

Also fixed a pre-existing invalid-HTML bug: ConditionPicker rendered a <Check>
SVG inside <option> (React hydration error spam) → plain "✓" text.

Build + 277 unit tests green; bell verified live (popover, actions, no errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 08:30:26 +02:00
parent 8ba38d893d
commit 3d2c68a1a1
9 changed files with 321 additions and 22 deletions
+6 -19
View File
@@ -1,14 +1,15 @@
import { useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { charactersRepo, encountersRepo, notesRepo } from '@/lib/db/repositories';
import { getSystem, applyRest } from '@/lib/rules';
import { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { addCombatant } from '@/lib/combat/engine';
import { loadMonsters, loadPf2e } from '@/lib/compendium';
import { resourceSuggestions, planningSuggestions, combatSuggestions, type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
import { type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
import { buildSignals } from '@/lib/assistant/signals';
import { useSignalAction } from './useSignalAction';
import { buildSuggestedEncounter } from '@/lib/assistant/encounter';
import { useUiStore } from '@/stores/uiStore';
import { useCharacters } from '@/features/characters/hooks';
@@ -43,25 +44,11 @@ function Assistant({ campaign }: { campaign: Campaign }) {
const [msg, setMsg] = useState<string | null>(null);
const suggestions = useMemo<Suggestion[]>(
() => [...combatSuggestions(activeEnc), ...resourceSuggestions(characters), ...planningSuggestions(notes, quests)],
() => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }),
[activeEnc, characters, notes, quests],
);
const runAction = async (a: SuggestAction) => {
if (a.type === 'goto') { void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to }); return; }
if (a.type === 'longRest') {
const c = await charactersRepo.get(a.characterId);
if (!c) return;
const opt = getSystem(c.system).restOptions.find((o) => o.restoresHp);
if (opt) await charactersRepo.update(c.id, applyRest(c, opt));
setMsg('Applied a long rest.');
return;
}
if (a.type === 'createNote') {
await notesRepo.create(campaign.id, a.title);
void navigate({ to: '/notes' });
}
};
const runAction = useSignalAction(campaign.id, setMsg);
const buildEncounter = async (difficulty: string) => {
setBusy(true);
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Bell, X } from 'lucide-react';
import type { Campaign } from '@/lib/schemas';
import { buildSignals } from '@/lib/assistant/signals';
import type { Suggestion } from '@/lib/assistant/advisors';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { useCharacters } from '@/features/characters/hooks';
import { useNotes, useQuests } from '@/features/world/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { useSignalAction } from './useSignalAction';
const DOT: Record<Suggestion['severity'], string> = {
danger: 'bg-danger',
warn: 'bg-warning',
info: 'bg-info',
};
/**
* Ambient, app-wide "what needs my attention" surface. Watches the active
* campaign's state and surfaces one-click signals from anywhere — the proactive
* core of the assistant. Renders nothing without an active campaign.
*/
export function SignalsBell() {
const campaign = useActiveCampaign();
if (!campaign) return null;
return <SignalsBellInner campaign={campaign} />;
}
function SignalsBellInner({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const notes = useNotes(campaign.id);
const quests = useQuests(campaign.id);
const encounters = useEncounters(campaign.id);
const activeEnc = encounters.find((e) => e.status === 'active');
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
const [open, setOpen] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const runAction = useSignalAction(campaign.id, setMsg);
const ref = useRef<HTMLDivElement>(null);
const signals = useMemo(
() => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }).filter((s) => !dismissed.has(s.id)),
[activeEnc, characters, notes, quests, dismissed],
);
// The badge counts only things that genuinely want attention.
const attention = signals.filter((s) => s.severity !== 'info').length;
// Close the popover on an outside click or Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
}, [open]);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
aria-label={`Signals${attention > 0 ? ` (${attention} need attention)` : ''}`}
title="Signals — things that may need your attention"
className="relative grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink"
>
<Bell size={18} aria-hidden />
{attention > 0 && (
<span className="absolute -right-0.5 -top-0.5 grid min-w-[16px] place-items-center rounded-full bg-danger px-1 text-[10px] font-semibold leading-4 text-white">
{attention}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-11 z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border border-line bg-panel p-2 shadow-xl">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Signals</span>
{signals.length > 0 && (
<button className="text-xs text-faint hover:text-ink" onClick={() => setDismissed(new Set(signals.map((s) => s.id)))}>Clear all</button>
)}
</div>
{msg && <p className="px-2 pb-1 text-xs text-success" aria-live="polite">{msg}</p>}
{signals.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted">Nothing needs your attention.</p>
) : (
<ul className="max-h-[60vh] space-y-1 overflow-y-auto">
{signals.map((s) => (
<li key={s.id} className="flex items-start gap-2 rounded-lg border border-line/70 bg-surface p-2">
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', DOT[s.severity])} aria-hidden />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ink">{s.title}</div>
{s.detail && <div className="text-xs text-muted">{s.detail}</div>}
{s.action && (
<Button size="sm" variant="secondary" className="mt-1.5" onClick={() => { void runAction(s.action!); if (s.action!.type !== 'goto') setMsg(null); setOpen(s.action!.type !== 'goto'); }}>
{s.action.label}
</Button>
)}
</div>
<button className="text-faint hover:text-ink" aria-label="Dismiss" onClick={() => setDismissed((d) => new Set(d).add(s.id))}>
<X size={13} aria-hidden />
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useNavigate } from '@tanstack/react-router';
import { charactersRepo, notesRepo, questsRepo } from '@/lib/db/repositories';
import { getSystem, applyRest } from '@/lib/rules';
import type { SuggestAction } from '@/lib/assistant/advisors';
/**
* One place that turns a signal's `SuggestAction` into a real mutation/navigation,
* shared by the ambient signals bell and the Assistant page so behaviour can't
* drift. `onDone` receives a short confirmation message for an aria-live region.
*/
export function useSignalAction(campaignId: string, onDone?: (msg: string) => void) {
const navigate = useNavigate();
return async (a: SuggestAction): Promise<void> => {
switch (a.type) {
case 'goto':
void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to });
return;
case 'longRest':
case 'shortRest': {
const c = await charactersRepo.get(a.characterId);
if (!c) return;
const opts = getSystem(c.system).restOptions;
const opt = a.type === 'longRest'
? opts.find((o) => o.restoresHp) ?? opts.at(-1)
: opts.find((o) => !o.restoresHp) ?? opts[0];
if (opt) await charactersRepo.update(c.id, applyRest(c, opt));
onDone?.(`Applied ${opt?.label ?? 'a rest'} for ${c.name}.`);
return;
}
case 'completeQuest':
await questsRepo.update(a.questId, { status: 'completed' });
onDone?.('Quest marked complete.');
return;
case 'createNote':
await notesRepo.create(campaignId, a.title);
void navigate({ to: '/notes' });
return;
}
};
}
+1 -2
View File
@@ -2,7 +2,6 @@ import { useRef, useState } from 'react';
import {
ArrowDown,
ArrowUp,
Check,
ChevronLeft,
ChevronRight,
Dices,
@@ -611,7 +610,7 @@ function ConditionPicker({
<option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}>
{cd.name}
{cd.valued ? ' (#)' : ''}
{taken.has(cd.name) ? <Check size={12} aria-hidden /> : ''}
{taken.has(cd.name) ? ' ✓' : ''}
</option>
))}
<option value="__custom__">Custom</option>