feat(health): add project health store, Mission Control bar, and session metrics
This commit is contained in:
parent
072316d63f
commit
42094eac2a
11 changed files with 773 additions and 16 deletions
|
|
@ -1,16 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { getAgentSessions } from '../../stores/agents.svelte';
|
||||
import { getActiveGroup, getEnabledProjects, getActiveGroupId } from '../../stores/workspace.svelte';
|
||||
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
|
||||
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
|
||||
|
||||
let agentSessions = $derived(getAgentSessions());
|
||||
let activeGroup = $derived(getActiveGroup());
|
||||
let enabledProjects = $derived(getEnabledProjects());
|
||||
|
||||
let activeAgents = $derived(agentSessions.filter(s => s.status === 'running' || s.status === 'starting').length);
|
||||
let totalCost = $derived(agentSessions.reduce((sum, s) => sum + s.costUsd, 0));
|
||||
let totalTokens = $derived(agentSessions.reduce((sum, s) => sum + s.inputTokens + s.outputTokens, 0));
|
||||
let projectCount = $derived(enabledProjects.length);
|
||||
let agentCount = $derived(agentSessions.length);
|
||||
|
||||
// Health-derived signals
|
||||
let health = $derived(getHealthAggregates());
|
||||
let attentionQueue = $derived(getAttentionQueue(5));
|
||||
|
||||
let showAttention = $state(false);
|
||||
|
||||
function projectName(projectId: string): string {
|
||||
return enabledProjects.find(p => p.id === projectId)?.name ?? projectId.slice(0, 8);
|
||||
}
|
||||
|
||||
function focusProject(projectId: string) {
|
||||
setActiveProject(projectId);
|
||||
showAttention = false;
|
||||
}
|
||||
|
||||
function formatRate(rate: number): string {
|
||||
if (rate < 0.01) return '$0/hr';
|
||||
if (rate < 1) return `$${rate.toFixed(2)}/hr`;
|
||||
return `$${rate.toFixed(1)}/hr`;
|
||||
}
|
||||
|
||||
function attentionColor(item: ProjectHealth): string {
|
||||
if (item.attentionScore >= 90) return 'var(--ctp-red)';
|
||||
if (item.attentionScore >= 70) return 'var(--ctp-peach)';
|
||||
if (item.attentionScore >= 40) return 'var(--ctp-yellow)';
|
||||
return 'var(--ctp-overlay1)';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="status-bar">
|
||||
|
|
@ -21,18 +48,49 @@
|
|||
{/if}
|
||||
<span class="item" title="Enabled projects">{projectCount} projects</span>
|
||||
<span class="sep"></span>
|
||||
<span class="item" title="Agent sessions">{agentCount} agents</span>
|
||||
{#if activeAgents > 0}
|
||||
<span class="sep"></span>
|
||||
<span class="item active">
|
||||
|
||||
<!-- Agent states from health store -->
|
||||
{#if health.running > 0}
|
||||
<span class="item state-running" title="Running agents">
|
||||
<span class="pulse"></span>
|
||||
{activeAgents} running
|
||||
{health.running} running
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
{#if health.idle > 0}
|
||||
<span class="item state-idle" title="Idle agents">{health.idle} idle</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
{#if health.stalled > 0}
|
||||
<span class="item state-stalled" title="Stalled agents (>15 min inactive)">
|
||||
{health.stalled} stalled
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
|
||||
<!-- Attention queue toggle -->
|
||||
{#if attentionQueue.length > 0}
|
||||
<button
|
||||
class="item attention-btn"
|
||||
class:attention-open={showAttention}
|
||||
onclick={() => showAttention = !showAttention}
|
||||
title="Needs attention — click to expand"
|
||||
>
|
||||
<span class="attention-dot"></span>
|
||||
{attentionQueue.length} need attention
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
{#if health.totalBurnRatePerHour > 0}
|
||||
<span class="item burn-rate" title="Total burn rate across active sessions">
|
||||
{formatRate(health.totalBurnRatePerHour)}
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
{#if totalTokens > 0}
|
||||
<span class="item tokens">{totalTokens.toLocaleString()} tokens</span>
|
||||
<span class="item tokens">{totalTokens.toLocaleString()} tok</span>
|
||||
<span class="sep"></span>
|
||||
{/if}
|
||||
{#if totalCost > 0}
|
||||
|
|
@ -43,6 +101,24 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attention queue dropdown -->
|
||||
{#if showAttention && attentionQueue.length > 0}
|
||||
<div class="attention-panel">
|
||||
{#each attentionQueue as item (item.projectId)}
|
||||
<button
|
||||
class="attention-card"
|
||||
onclick={() => focusProject(item.projectId)}
|
||||
>
|
||||
<span class="card-name">{projectName(item.projectId)}</span>
|
||||
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
|
||||
{#if item.contextPressure !== null && item.contextPressure > 0.5}
|
||||
<span class="card-ctx" title="Context usage">ctx {Math.round(item.contextPressure * 100)}%</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.status-bar {
|
||||
display: flex;
|
||||
|
|
@ -57,6 +133,7 @@
|
|||
font-family: 'JetBrains Mono', monospace;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.left, .right {
|
||||
|
|
@ -82,15 +159,25 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--ctp-blue);
|
||||
/* Agent state indicators */
|
||||
.state-running {
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.state-idle {
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.state-stalled {
|
||||
color: var(--ctp-peach);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-blue);
|
||||
background: var(--ctp-green);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +186,101 @@
|
|||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Attention button */
|
||||
.attention-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ctp-peach);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attention-btn:hover {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.attention-btn.attention-open {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.attention-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-peach);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.attention-btn.attention-open .attention-dot,
|
||||
.attention-btn:hover .attention-dot {
|
||||
background: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* Burn rate */
|
||||
.burn-rate {
|
||||
color: var(--ctp-mauve);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tokens { color: var(--ctp-overlay1); }
|
||||
.cost { color: var(--ctp-yellow); }
|
||||
.version { color: var(--ctp-overlay0); }
|
||||
|
||||
/* Attention panel dropdown */
|
||||
.attention-panel {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--ctp-surface0);
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
z-index: 100;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.attention-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attention-card:hover {
|
||||
background: var(--ctp-surface0);
|
||||
border-color: var(--ctp-surface2);
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.card-reason {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.card-ctx {
|
||||
font-size: 0.5625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 10%, transparent);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
type AgentMessageRecord,
|
||||
} from '../../adapters/groups-bridge';
|
||||
import { registerSessionProject } from '../../agent-dispatcher';
|
||||
import { trackProject, updateProjectSession } from '../../stores/health.svelte';
|
||||
import {
|
||||
createAgentSession,
|
||||
appendAgentMessages,
|
||||
|
|
@ -35,6 +36,7 @@
|
|||
hasRestoredHistory = false;
|
||||
lastState = null;
|
||||
registerSessionProject(sessionId, project.id);
|
||||
trackProject(project.id, sessionId);
|
||||
onsessionid?.(sessionId);
|
||||
}
|
||||
|
||||
|
|
@ -67,8 +69,9 @@
|
|||
sessionId = crypto.randomUUID();
|
||||
} finally {
|
||||
loading = false;
|
||||
// Register session -> project mapping for persistence
|
||||
// Register session -> project mapping for persistence + health tracking
|
||||
registerSessionProject(sessionId, project.id);
|
||||
trackProject(project.id, sessionId);
|
||||
onsessionid?.(sessionId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import SshTab from './SshTab.svelte';
|
||||
import MemoriesTab from './MemoriesTab.svelte';
|
||||
import { getTerminalTabs } from '../../stores/workspace.svelte';
|
||||
import { getProjectHealth } from '../../stores/health.svelte';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
let everActivated = $state<Record<string, boolean>>({});
|
||||
|
||||
let termTabs = $derived(getTerminalTabs(project.id));
|
||||
let projectHealth = $derived(getProjectHealth(project.id));
|
||||
let termTabCount = $derived(termTabs.length);
|
||||
|
||||
/** Activate a tab — for lazy tabs, mark as ever-activated */
|
||||
|
|
@ -56,6 +58,7 @@
|
|||
{project}
|
||||
{slotIndex}
|
||||
{active}
|
||||
health={projectHealth}
|
||||
onclick={onactivate}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { ProjectConfig } from '../../types/groups';
|
||||
import { PROJECT_ACCENTS } from '../../types/groups';
|
||||
import type { ProjectHealth } from '../../stores/health.svelte';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
slotIndex: number;
|
||||
active: boolean;
|
||||
health: ProjectHealth | null;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { project, slotIndex, active, onclick }: Props = $props();
|
||||
let { project, slotIndex, active, health, onclick }: Props = $props();
|
||||
|
||||
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
|
||||
|
||||
|
|
@ -25,6 +27,44 @@
|
|||
}
|
||||
return cwd;
|
||||
});
|
||||
|
||||
let statusDotClass = $derived(() => {
|
||||
if (!health) return 'dot-inactive';
|
||||
switch (health.activityState) {
|
||||
case 'running': return 'dot-running';
|
||||
case 'idle': return 'dot-idle';
|
||||
case 'stalled': return 'dot-stalled';
|
||||
default: return 'dot-inactive';
|
||||
}
|
||||
});
|
||||
|
||||
let statusTooltip = $derived(() => {
|
||||
if (!health) return 'No active session';
|
||||
switch (health.activityState) {
|
||||
case 'running': return health.activeTool ? `Running: ${health.activeTool}` : 'Running';
|
||||
case 'idle': {
|
||||
const secs = Math.floor(health.idleDurationMs / 1000);
|
||||
return secs < 60 ? `Idle (${secs}s)` : `Idle (${Math.floor(secs / 60)}m ${secs % 60}s)`;
|
||||
}
|
||||
case 'stalled': {
|
||||
const mins = Math.floor(health.idleDurationMs / 60_000);
|
||||
return `Stalled — ${mins} min since last activity`;
|
||||
}
|
||||
default: return 'Inactive';
|
||||
}
|
||||
});
|
||||
|
||||
let contextPct = $derived(health?.contextPressure !== null && health?.contextPressure !== undefined
|
||||
? Math.round(health.contextPressure * 100)
|
||||
: null);
|
||||
|
||||
let ctxColor = $derived(() => {
|
||||
if (contextPct === null) return '';
|
||||
if (contextPct > 90) return 'var(--ctp-red)';
|
||||
if (contextPct > 75) return 'var(--ctp-peach)';
|
||||
if (contextPct > 50) return 'var(--ctp-yellow)';
|
||||
return 'var(--ctp-overlay0)';
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
|
|
@ -34,11 +74,22 @@
|
|||
{onclick}
|
||||
>
|
||||
<div class="header-main">
|
||||
<span class="status-dot {statusDotClass()}" title={statusTooltip()}></span>
|
||||
<span class="project-icon">{project.icon || '📁'}</span>
|
||||
<span class="project-name">{project.name}</span>
|
||||
<span class="project-id">({project.identifier})</span>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
{#if contextPct !== null && contextPct > 0}
|
||||
<span class="info-ctx" style="color: {ctxColor()}" title="Context window usage">ctx {contextPct}%</span>
|
||||
<span class="info-sep">·</span>
|
||||
{/if}
|
||||
{#if health && health.burnRatePerHour > 0.01}
|
||||
<span class="info-rate" title="Burn rate">
|
||||
${health.burnRatePerHour < 1 ? health.burnRatePerHour.toFixed(2) : health.burnRatePerHour.toFixed(1)}/hr
|
||||
</span>
|
||||
<span class="info-sep">·</span>
|
||||
{/if}
|
||||
<span class="info-cwd" title={project.cwd}>{displayCwd()}</span>
|
||||
{#if project.profile}
|
||||
<span class="info-sep">·</span>
|
||||
|
|
@ -83,6 +134,37 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-inactive {
|
||||
background: var(--ctp-surface2);
|
||||
}
|
||||
|
||||
.dot-running {
|
||||
background: var(--ctp-green);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-idle {
|
||||
background: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.dot-stalled {
|
||||
background: var(--ctp-peach);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
|
|
@ -109,6 +191,20 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-ctx {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-rate {
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-mauve);
|
||||
font-family: var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-cwd {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue