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:
@@ -19,6 +19,7 @@ import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
||||
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
||||
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
|
||||
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
||||
import { SignalsBell } from '@/features/assistant/SignalsBell';
|
||||
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
|
||||
@@ -205,6 +206,7 @@ export function RootLayout() {
|
||||
</Button>
|
||||
{activeCampaign && <HandoutControl />}
|
||||
<SessionControl />
|
||||
<SignalsBell />
|
||||
<ThemeToggle />
|
||||
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
|
||||
<SettingsIcon size={18} aria-hidden />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,8 @@ export type Severity = 'info' | 'warn' | 'danger';
|
||||
export type SuggestAction =
|
||||
| { type: 'goto'; to: string; params?: Record<string, string>; label: string }
|
||||
| { type: 'longRest'; characterId: string; label: string }
|
||||
| { type: 'shortRest'; characterId: string; label: string }
|
||||
| { type: 'completeQuest'; questId: string; label: string }
|
||||
| { type: 'createNote'; title: string; label: string };
|
||||
|
||||
export interface Suggestion {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { characterDefaults, type Character, type Quest } from '@/lib/schemas';
|
||||
import { questSignals, casterSignals, buildSignals } from './signals';
|
||||
|
||||
function pc(over: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'pc', campaignId: 'c', system: '5e', kind: 'pc', name: 'Mage', ancestry: '', className: 'Wizard', level: 5,
|
||||
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
|
||||
hp: { current: 30, max: 30, temp: 0 }, speed: 30, armorBonus: 0,
|
||||
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
function quest(over: Partial<Quest> = {}): Quest {
|
||||
return { id: 'q', campaignId: 'c', title: 'Find the relic', status: 'active', description: '', reward: '', objectives: [], createdAt: '', updatedAt: '', ...over };
|
||||
}
|
||||
|
||||
describe('questSignals', () => {
|
||||
it('offers to complete a quest whose objectives are all done', () => {
|
||||
const q = quest({ objectives: [{ id: 'o1', text: 'a', done: true }, { id: 'o2', text: 'b', done: true }] });
|
||||
const [s] = questSignals([q]);
|
||||
expect(s?.action).toEqual({ type: 'completeQuest', questId: 'q', label: 'Mark complete' });
|
||||
});
|
||||
|
||||
it('stays quiet when an objective is unfinished or there are none', () => {
|
||||
expect(questSignals([quest({ objectives: [{ id: 'o1', text: 'a', done: false }] })])).toEqual([]);
|
||||
expect(questSignals([quest({ objectives: [] })])).toEqual([]);
|
||||
expect(questSignals([quest({ status: 'completed', objectives: [{ id: 'o1', text: 'a', done: true }] })])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('casterSignals', () => {
|
||||
it('flags a caster with all slots expended and offers a short rest', () => {
|
||||
const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 0 }, { level: 2, max: 2, current: 0 }], spells: [] } });
|
||||
const sig = casterSignals([c]).find((s) => s.id === 'no-slots-pc');
|
||||
expect(sig?.action).toEqual({ type: 'shortRest', characterId: 'pc', label: 'Short rest' });
|
||||
});
|
||||
|
||||
it('does not flag when some slots remain', () => {
|
||||
const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 1 }], spells: [] } });
|
||||
expect(casterSignals([c]).some((s) => s.id === 'no-slots-pc')).toBe(false);
|
||||
});
|
||||
|
||||
it('warns when concentrating while bloodied', () => {
|
||||
const c = pc({ hp: { current: 10, max: 30, temp: 0 }, concentration: { spellId: 's', spellName: 'Bless' } });
|
||||
expect(casterSignals([c]).some((s) => s.id === 'conc-risk-pc')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSignals', () => {
|
||||
it('merges detectors, de-dupes, and sorts danger → warn → info', () => {
|
||||
const downed = pc({ id: 'a', name: 'A', hp: { current: 0, max: 30, temp: 0 } });
|
||||
const noSlots = pc({ id: 'b', name: 'B', spellcasting: { slots: [{ level: 1, max: 2, current: 0 }], spells: [] } });
|
||||
const out = buildSignals({ characters: [downed, noSlots], notes: [], quests: [], activeEncounter: undefined });
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
// severity is non-decreasing (danger=0 first)
|
||||
const ranks = out.map((s) => ({ danger: 0, warn: 1, info: 2 })[s.severity]);
|
||||
expect(ranks).toEqual([...ranks].sort((x, y) => x - y));
|
||||
// unique ids
|
||||
expect(new Set(out.map((s) => s.id)).size).toBe(out.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Character, Encounter, Note, Quest } from '@/lib/schemas';
|
||||
import {
|
||||
resourceSuggestions,
|
||||
planningSuggestions,
|
||||
combatSuggestions,
|
||||
type Suggestion,
|
||||
} from './advisors';
|
||||
|
||||
/**
|
||||
* The Signals engine: pure detectors over campaign + combat + character state
|
||||
* that emit actionable, one-click proposals. This is the "buttons, not chatbot"
|
||||
* layer — it recognizes a situation and offers the fix; the user never has to
|
||||
* formulate a request. It builds on the existing advisor detectors and adds
|
||||
* kernel-aware ones now that combat/spellcasting state is enforced.
|
||||
*/
|
||||
|
||||
/** A quest whose objectives are all done but is still marked active → offer to complete it. */
|
||||
export function questSignals(quests: Quest[]): Suggestion[] {
|
||||
const out: Suggestion[] = [];
|
||||
for (const q of quests) {
|
||||
if (q.status !== 'active' || q.objectives.length === 0) continue;
|
||||
if (q.objectives.every((o) => o.done)) {
|
||||
out.push({
|
||||
id: `quest-done-${q.id}`,
|
||||
category: 'planning',
|
||||
severity: 'info',
|
||||
title: `“${q.title}” is finished`,
|
||||
detail: 'Every objective is checked off — mark the quest complete?',
|
||||
action: { type: 'completeQuest', questId: q.id, label: 'Mark complete' },
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Spellcasters who have burned through their slots, and concentration-at-risk nudges. */
|
||||
export function casterSignals(characters: Character[]): Suggestion[] {
|
||||
const out: Suggestion[] = [];
|
||||
for (const c of characters) {
|
||||
if (c.kind !== 'pc' || c.hp.current <= 0) continue;
|
||||
|
||||
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
|
||||
if (slots.length > 0 && slots.every((s) => s.current === 0)) {
|
||||
out.push({
|
||||
id: `no-slots-${c.id}`,
|
||||
category: 'resources',
|
||||
severity: 'warn',
|
||||
title: `${c.name} is out of spell slots`,
|
||||
detail: 'No slots remaining — a rest would restore them.',
|
||||
action: { type: 'shortRest', characterId: c.id, label: 'Short rest' },
|
||||
});
|
||||
}
|
||||
|
||||
// Concentration while bloodied is fragile — surface it so the GM keeps it in mind.
|
||||
if (c.concentration && c.hp.max > 0 && c.hp.current < c.hp.max * 0.5) {
|
||||
out.push({
|
||||
id: `conc-risk-${c.id}`,
|
||||
category: 'combat',
|
||||
severity: 'info',
|
||||
title: `${c.name} is concentrating on ${c.concentration.spellName} while bloodied`,
|
||||
detail: 'Damage will force a Constitution save — watch for it.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SEVERITY_RANK: Record<Suggestion['severity'], number> = { danger: 0, warn: 1, info: 2 };
|
||||
|
||||
export interface BuildSignalsInput {
|
||||
characters: Character[];
|
||||
notes: Note[];
|
||||
quests: Quest[];
|
||||
activeEncounter?: Encounter | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate every detector into one severity-sorted, de-duplicated list — the
|
||||
* single source the ambient signals UI and the Assistant page both render.
|
||||
*/
|
||||
export function buildSignals(input: BuildSignalsInput): Suggestion[] {
|
||||
const all = [
|
||||
...combatSuggestions(input.activeEncounter),
|
||||
...casterSignals(input.characters),
|
||||
...resourceSuggestions(input.characters),
|
||||
...questSignals(input.quests),
|
||||
...planningSuggestions(input.notes, input.quests),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
const deduped = all.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
|
||||
return deduped.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user