Add scout constellation mode for APK venue persona packs.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Cluster 3+ scout_report hits on the same SSID within 10 minutes; server infers airport/campus/retail venue class and pushes persona spread_policy. Emberwake weather-map merges active scout biomes. Includes agent, server API, and Vitest coverage.
This commit is contained in:
@@ -291,3 +291,56 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.access-depth-strain-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.access-depth-strain-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-left: 3px solid var(--strain-accent, #6a8fad);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.45rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.access-depth-strain-card[data-strain] {
|
||||
--strain-accent: #6a8fad;
|
||||
}
|
||||
|
||||
.access-depth-strain-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.access-depth-strain-card-title {
|
||||
flex: 1;
|
||||
color: #e0e8f0;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.access-depth-strain-play {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #c8e6ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.access-depth-strain-play:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.access-depth-strain-card-meta {
|
||||
font-size: 0.65rem;
|
||||
color: #98a8b8;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseAccessDepthDiagnostics,
|
||||
parseAccessDepthServerPolicy,
|
||||
} from '../../help/accessDepth';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
vi.mock('../../hooks/useWebSocket', () => ({
|
||||
useWebSocket: () => ({ latestMessage: null }),
|
||||
@@ -27,6 +28,8 @@ vi.mock('../../api/client', () => ({
|
||||
},
|
||||
},
|
||||
}),
|
||||
listStrainCards: vi.fn().mockResolvedValue([]),
|
||||
playStrainCard: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -228,6 +231,32 @@ describe('AccessDepthPanel', () => {
|
||||
expect(screen.getByText(/parent 11112222/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders lineage strain card with play control', async () => {
|
||||
vi.mocked(api.listStrainCards).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'card-1',
|
||||
root_agent_id: 'root',
|
||||
source_agent_id: 'a1',
|
||||
source_agent_name: 'Winner',
|
||||
spread_strain: '#a1b2c3',
|
||||
spread_lane: 'dns_txt',
|
||||
persona: 'persuasive',
|
||||
parents: [],
|
||||
wins: ['container', 'wsl'],
|
||||
losses: ['docker'],
|
||||
subnets: ['10.0.0.x'],
|
||||
erasure_recovery_rate: 1,
|
||||
peak_hashrate: 800,
|
||||
tier_order: ['container', 'wsl'],
|
||||
tree_size: 2,
|
||||
},
|
||||
]);
|
||||
renderPanel(mockAgent({ id: 'a1', status: 'online' }));
|
||||
expect(await screen.findByText(/strain · persuasive/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2W · 1L · 1 subnets · erasure 100%/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /play/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders clearance badge L0–L4 with tooltip permissions', () => {
|
||||
renderPanel(mockAgent({ clearance_level: 2 }));
|
||||
const badge = screen.getByLabelText(/Clearance L2/i);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
formatClearanceElevation,
|
||||
} from '../../help/clearance';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import type { Agent } from '../../types';
|
||||
import type { Agent, StrainCard } from '../../types';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import JoinLaneBadge from './JoinLaneBadge';
|
||||
import LotlTierBadge from './LotlTierBadge';
|
||||
@@ -77,6 +77,8 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
const [policyLoaded, setPolicyLoaded] = useState(false);
|
||||
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
|
||||
const [elevationFlash, setElevationFlash] = useState<string | null>(null);
|
||||
const [strainCards, setStrainCards] = useState<StrainCard[]>([]);
|
||||
const [strainPlayBusy, setStrainPlayBusy] = useState<string | null>(null);
|
||||
const flashTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearanceLevel = agent.clearance_level ?? 1;
|
||||
@@ -124,6 +126,44 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.listStrainCards(agent.id)
|
||||
.then((cards) => {
|
||||
if (!cancelled) setStrainCards(cards ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStrainCards([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agent.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
if (latestMessage.type === 'strain_card' || latestMessage.type === 'strain_card_played') {
|
||||
const p = latestMessage.payload as { card?: StrainCard; agent_id?: string };
|
||||
if (p.card && (p.card.source_agent_id === agent.id || p.card.root_agent_id === agent.id || p.agent_id === agent.id)) {
|
||||
setStrainCards((prev) => {
|
||||
const next = prev.filter((c) => c.id !== p.card!.id);
|
||||
return [p.card!, ...next];
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [latestMessage, agent.id]);
|
||||
|
||||
const playStrainCard = async (card: StrainCard) => {
|
||||
if (strainPlayBusy) return;
|
||||
setStrainPlayBusy(card.id);
|
||||
try {
|
||||
await api.playStrainCard({ agent_id: agent.id, card_id: card.id });
|
||||
} finally {
|
||||
setStrainPlayBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const model = useMemo(
|
||||
() => buildAccessDepthModel(agent, diagnostics, serverPolicy),
|
||||
[agent, diagnostics, serverPolicy],
|
||||
@@ -205,6 +245,45 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{strainCards.length > 0 && (
|
||||
<div className="access-depth-strain-cards">
|
||||
{strainCards.slice(0, 2).map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="access-depth-strain-card"
|
||||
data-strain={card.spread_strain?.replace(/^#/, '') ?? ''}
|
||||
>
|
||||
<div className="access-depth-strain-card-head">
|
||||
{card.spread_strain ? (
|
||||
<span
|
||||
className="access-depth-strain-swatch"
|
||||
style={{ backgroundColor: card.spread_strain }}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span className="access-depth-strain-card-title">
|
||||
strain · {card.persona}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="access-depth-strain-play"
|
||||
disabled={agent.status !== 'online' || strainPlayBusy === card.id}
|
||||
onClick={() => playStrainCard(card)}
|
||||
title={`Play ${card.source_agent_name} lineage preset`}
|
||||
>
|
||||
{strainPlayBusy === card.id ? '…' : 'play'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="access-depth-strain-card-meta">
|
||||
{card.wins.length}W · {card.losses.length}L · {card.subnets.length} subnets
|
||||
{card.erasure_recovery_rate > 0
|
||||
? ` · erasure ${Math.round(card.erasure_recovery_rate * 100)}%`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{model.phenotypeSource && (
|
||||
<div className="access-depth-phenotype">
|
||||
phenotype cloned from <strong>{model.phenotypeSource}</strong>
|
||||
@@ -216,6 +295,13 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{serverPolicy.graft_enabled && (agent.graft_tier || agent.graft_source_strain) && (
|
||||
<div className="access-depth-graft-note access-depth-muted">
|
||||
genealogy graft pending · tier {agent.graft_tier}
|
||||
{agent.graft_source_strain ? ` · strain ${agent.graft_source_strain}` : ''}
|
||||
{' '}(applies on next spread)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="access-depth-section">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SacredMotif } from '../Visual/sacredGeometry/motifs';
|
||||
import SetupBanner from '../SetupBanner';
|
||||
import { getSetupStatus } from '../../help/setupStatus';
|
||||
import { resolvePageWeather } from '../../help/pageWeather';
|
||||
import { mergeScoutBiomeWeather, type ScoutConstellationSnapshot } from '../../help/scoutBiomeWeather';
|
||||
import { isDashboardRoute } from '../../help/routeEffects';
|
||||
import { api } from '../../api/client';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
@@ -40,10 +41,11 @@ function operatorDeckId(pathname: string): string {
|
||||
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
|
||||
if (path.startsWith('/roi')) return 'roi';
|
||||
if (path.startsWith('/activity')) return 'activity';
|
||||
if (path.startsWith('/seer')) return 'seer';
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
const NAV_BASE = [
|
||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||
{ to: '/activity', label: 'Activity Feed', icon: 'activity' },
|
||||
@@ -57,6 +59,20 @@ const NAV = [
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
] as const;
|
||||
|
||||
const SEER_NAV = { to: '/seer', label: 'Seer', icon: 'seer' } as const;
|
||||
|
||||
function buildNav(aiControlEnabled: boolean) {
|
||||
if (!aiControlEnabled) {
|
||||
return [...NAV_BASE];
|
||||
}
|
||||
const items = [...NAV_BASE];
|
||||
const calibrateIdx = items.findIndex((i) => i.to === '/settings');
|
||||
items.splice(calibrateIdx, 0, SEER_NAV);
|
||||
return items;
|
||||
}
|
||||
|
||||
const NAV = NAV_BASE;
|
||||
|
||||
const DOCS_HREF = '/docs/';
|
||||
|
||||
/** Primary tabs on mobile bottom bar — Deck, Crucible, Activity, ROI, Onion */
|
||||
@@ -150,6 +166,14 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M12 8v4" />
|
||||
</svg>
|
||||
);
|
||||
case 'seer':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<ellipse cx="12" cy="12" rx="9" ry="5" />
|
||||
<circle cx="12" cy="12" r="2.5" fill="currentColor" strokeWidth="0" />
|
||||
<path d="M4 12c2-3 5-4.5 8-4.5s6 1.5 8 4.5" strokeOpacity="0.45" />
|
||||
</svg>
|
||||
);
|
||||
case 'docs':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -281,7 +305,19 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}, [moreOpen]);
|
||||
|
||||
const setupStatus = getSetupStatus(serverConfig, serverInfo);
|
||||
const pageWeather = resolvePageWeather(location.pathname);
|
||||
const { latestMessage } = useWebSocket();
|
||||
const scoutBiome = useMemo(() => {
|
||||
if (latestMessage?.type !== 'scout_constellations') return null;
|
||||
return latestMessage.payload as ScoutConstellationSnapshot;
|
||||
}, [latestMessage]);
|
||||
const pageWeather = useMemo(() => {
|
||||
const base = resolvePageWeather(location.pathname);
|
||||
const path = location.pathname.split('?')[0].replace(/\/$/, '') || '/';
|
||||
if (path === '/emberwake' || path === '/spread' || path === '/dashboard' || path === '/agents') {
|
||||
return mergeScoutBiomeWeather(base, scoutBiome);
|
||||
}
|
||||
return base;
|
||||
}, [location.pathname, scoutBiome]);
|
||||
const showDeckEffects = isDashboardRoute(location.pathname);
|
||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||
const mobileShortLabel: Record<string, string> = {
|
||||
|
||||
Reference in New Issue
Block a user