diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index a963477..6c10328 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router'; import { Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower, LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight, - ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, + ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, type LucideIcon, } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; @@ -31,6 +31,7 @@ const NAV: NavItem[] = [ { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' }, { to: '/characters', label: 'Characters', icon: UserRound, exact: false, group: 'play' }, { to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' }, + { to: '/director', label: 'AI Director', icon: Wand2, exact: false, group: 'play' }, { to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' }, { to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' }, { to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' }, diff --git a/src/features/assistant/director/ActionCard.tsx b/src/features/assistant/director/ActionCard.tsx new file mode 100644 index 0000000..504881f --- /dev/null +++ b/src/features/assistant/director/ActionCard.tsx @@ -0,0 +1,16 @@ +import { Badge } from '@/components/ui/Codex'; +import type { DirectorAction } from '@/lib/assistant/director'; +import { actionSummary } from './actionSummary'; + +/** + * D1: a passive preview of a proposed change. The one-click Apply (routed through + * the rules kernel, approve-each) arrives in phase D2. + */ +export function ActionCard({ action }: { action: DirectorAction }) { + return ( +
+ proposed + {actionSummary(action)} +
+ ); +} diff --git a/src/features/assistant/director/DirectorPage.tsx b/src/features/assistant/director/DirectorPage.tsx new file mode 100644 index 0000000..1972bec --- /dev/null +++ b/src/features/assistant/director/DirectorPage.tsx @@ -0,0 +1,106 @@ +import { Link } from '@tanstack/react-router'; +import type { Campaign } from '@/lib/schemas'; +import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Codex'; +import { useDirectorSession } from './useDirectorSession'; +import { TranscriptPane } from './TranscriptPane'; +import { RollRequestCard } from './RollRequestCard'; +import { ActionCard } from './ActionCard'; +import { SuggestionChips } from './SuggestionChips'; + +export function DirectorPage() { + return {(c) => }; +} + +function Director({ campaign }: { campaign: Campaign }) { + const { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } = + useDirectorSession(campaign); + const entries = session?.transcript ?? []; + const started = entries.length > 0; + + return ( + + + {source && {source === 'llm' ? 'AI' : 'deterministic'}} + {started && ( + + )} + + } + /> + + {!llmReady && ( +

+ No AI key configured — the director runs in deterministic mode. Add a key in{' '} + + Settings + {' '} + for full narration. +

+ )} + {message && ( +

+ {message} +

+ )} + {activeEnc && ( +

+ Running combat: {activeEnc.name} (round {activeEnc.round}). +

+ )} + +
+
+ + + {lastTurn && lastTurn.rollRequests.length > 0 && ( +
+
Rolls — you roll these
+ {lastTurn.rollRequests.map((r) => ( + + ))} +
+ )} + + {lastTurn && lastTurn.actions.length > 0 && ( +
+
Proposed changes
+ {lastTurn.actions.map((a, i) => ( + + ))} +

+ One-click Apply (routed through the rules engine) arrives in the next update — for now, resolve these on + your sheet and tracker. +

+
+ )} +
+ + +
+
+ ); +} diff --git a/src/features/assistant/director/RollRequestCard.tsx b/src/features/assistant/director/RollRequestCard.tsx new file mode 100644 index 0000000..dd42891 --- /dev/null +++ b/src/features/assistant/director/RollRequestCard.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { Dices } from 'lucide-react'; +import type { SystemId } from '@/lib/rules'; +import { rollAndShow } from '@/lib/useRoll'; +import type { Degree } from '@/lib/dice/check'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Codex'; +import type { RollRequest } from '@/lib/assistant/director'; + +/** + * The ONLY place the director feature touches the roll seam. A roll fires solely + * from this button's onClick — never automatically — honoring the hard no-auto-roll + * rule. The result is reported back so the director can adjudicate next turn. + */ +export function RollRequestCard({ + req, + system, + onRolled, +}: { + req: RollRequest; + system: SystemId; + onRolled: (req: RollRequest, result: { total: number; degree?: Degree }) => void; +}) { + const [done, setDone] = useState<{ total: number; degree?: Degree } | null>(null); + + const roll = () => { + const result = rollAndShow({ + expression: req.expression, + label: req.label, + ...(req.dc !== undefined ? { dc: req.dc, system } : {}), + }); + setDone(result); + onRolled(req, result); + }; + + return ( +
+ +
+
+ {req.label} + {req.against ? vs {req.against} : null} +
+
+ {req.expression} + {req.dc !== undefined ? ` · DC ${req.dc}` : ''} +
+
+ {done ? ( + + {done.total} + {done.degree ? ` · ${done.degree.includes('critical') ? 'Crit ' : ''}${done.degree.includes('success') ? 'Success' : 'Fail'}` : ''} + + ) : ( + + )} +
+ ); +} diff --git a/src/features/assistant/director/SuggestionChips.tsx b/src/features/assistant/director/SuggestionChips.tsx new file mode 100644 index 0000000..b93098e --- /dev/null +++ b/src/features/assistant/director/SuggestionChips.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Textarea } from '@/components/ui/Input'; + +/** Quick-reply suggestion chips + a free-text box for the human's action/intent. */ +export function SuggestionChips({ + suggestions, + disabled, + onPick, +}: { + suggestions: string[]; + disabled: boolean; + onPick: (text: string) => void; +}) { + const [text, setText] = useState(''); + const send = () => { + const t = text.trim(); + if (!t) return; + setText(''); + onPick(t); + }; + + return ( +
+ {suggestions.length > 0 && ( +
+ {suggestions.map((s, i) => ( + + ))} +
+ )} +