diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a850297ea..e2751482c 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -26,6 +26,8 @@ runtime cost control, and shared AI transport surfaces. 4. `internal/api/ai_intelligence_handlers.go` 5. `frontend-modern/src/api/ai.ts` 6. `frontend-modern/src/api/patrol.ts` +7. `frontend-modern/src/components/AI/Chat/` +8. `frontend-modern/src/utils/textPresentation.ts` ## Shared Boundaries @@ -40,6 +42,7 @@ runtime cost control, and shared AI transport surfaces. 1. Add or change chat runtime, Patrol orchestration, findings generation, or remediation behavior through `internal/ai/` 2. Add or change Pulse Assistant request flow through `internal/api/ai_handler.go` and `frontend-modern/src/api/ai.ts` 3. Add or change Patrol, alert-analysis, or remediation transport through `internal/api/ai_handlers.go`, `internal/api/ai_intelligence_handlers.go`, and `frontend-modern/src/api/patrol.ts` +4. Keep AI chat presentation helpers aligned through `frontend-modern/src/components/AI/Chat/` and the shared `frontend-modern/src/utils/textPresentation.ts` ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 07c26434f..85367455d 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -34,6 +34,7 @@ helpers. 11. `frontend-modern/src/utils/patrolFormat.ts` 12. `frontend-modern/src/utils/patrolRunPresentation.ts` 13. `frontend-modern/src/utils/patrolSummaryPresentation.ts` +14. `frontend-modern/src/utils/textPresentation.ts` ## Shared Boundaries @@ -43,7 +44,8 @@ helpers. 1. Add or change Patrol page orchestration through `frontend-modern/src/pages/AIIntelligence.tsx` and `frontend-modern/src/stores/aiIntelligence.ts` 2. Add or change Patrol findings, approvals, investigation, or run-history presentation through `frontend-modern/src/components/AI/FindingsPanel.tsx` and `frontend-modern/src/components/patrol/` -3. Keep Patrol transport and payload changes aligned through the governed AI runtime and API contract transport surfaces +3. Keep Patrol and chat identifier-label presentation aligned through the shared `frontend-modern/src/utils/textPresentation.ts` +4. Keep Patrol transport and payload changes aligned through the governed AI runtime and API contract transport surfaces ## Forbidden Paths diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 90fdec90e..117c37bfb 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -6,6 +6,7 @@ import { ToolExecutionBlock } from './ToolExecutionBlock'; import { ApprovalCard } from './ApprovalCard'; import { QuestionCard } from './QuestionCard'; import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent } from './types'; +import { formatIdentifierLabel } from '@/utils/textPresentation'; interface MessageItemProps { message: ChatMessage; @@ -102,8 +103,6 @@ export const MessageItem: Component = (props) => { return Array.from(names); }); - const formatToolName = (name: string) => name.replace(/^pulse_/, '').replace(/_/g, ' '); - // Check if currently streaming content (no tools pending, still streaming) const isStreamingText = () => props.message.isStreaming && @@ -236,7 +235,7 @@ export const MessageItem: Component = (props) => {
{contextTools().map((name) => ( - {formatToolName(name)} + {formatIdentifierLabel(name, { stripPrefix: 'pulse_' })} ))}
diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index 3826d3060..1957ceaa1 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -1,6 +1,7 @@ import { Component, Show, createSignal, createMemo, For } from 'solid-js'; import type { ToolExecution, PendingTool } from './types'; import { getToolCallResultTextClass } from '@/utils/patrolRunPresentation'; +import { formatIdentifierLabel } from '@/utils/textPresentation'; interface ToolExecutionBlockProps { tool: ToolExecution; @@ -28,10 +29,7 @@ export const ToolExecutionBlock: Component = (props) => if (name === 'pulse_get_storage_config') return 'storage cfg'; if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource'; if (name.includes('finding')) return 'finding'; - return name - .replace(/^pulse_/, '') - .replace(/_/g, ' ') - .substring(0, 12); + return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 }); }); // Check if output is non-empty and interesting @@ -155,10 +153,7 @@ export const PendingToolBlock: Component = (props) => { if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage'; if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource'; if (name.includes('finding')) return 'finding'; - return name - .replace(/^pulse_/, '') - .replace(/_/g, ' ') - .substring(0, 12); + return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 }); }); return ( diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index f418d8028..fca42e11b 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -678,7 +678,7 @@ describe('MessageItem', () => { )); expect(screen.getByText('Context used')).toBeInTheDocument(); - // formatToolName strips 'pulse_' and replaces underscores + // Shared identifier formatter strips 'pulse_' and replaces underscores expect(screen.getByText('get nodes')).toBeInTheDocument(); expect(screen.getByText('get metrics')).toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index adbd46dd8..ba1fd6378 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -36,6 +36,7 @@ import { ChatMessages } from './ChatMessages'; import { ModelSelector } from './ModelSelector'; import { MentionAutocomplete, type MentionResource } from './MentionAutocomplete'; import type { PendingApproval, PendingQuestion, ModelInfo } from './types'; +import { formatIdentifierLabel } from '@/utils/textPresentation'; const MODEL_SESSION_STORAGE_KEY = 'pulse:ai_chat_models_by_session'; const DEFAULT_SESSION_KEY = '__default__'; @@ -276,7 +277,7 @@ export const AIChat: Component = (props) => { if (lastMessage.pendingTools && lastMessage.pendingTools.length > 0) { const tool = lastMessage.pendingTools[0]; - const toolName = tool.name.replace(/^pulse_/, '').replace(/_/g, ' '); + const toolName = formatIdentifierLabel(tool.name, { stripPrefix: 'pulse_' }); return { type: 'tool', text: `Running ${toolName}...` }; } diff --git a/frontend-modern/src/components/patrol/PatrolStatusBar.tsx b/frontend-modern/src/components/patrol/PatrolStatusBar.tsx index 8698c5eec..ae6dd981b 100644 --- a/frontend-modern/src/components/patrol/PatrolStatusBar.tsx +++ b/frontend-modern/src/components/patrol/PatrolStatusBar.tsx @@ -9,6 +9,7 @@ import { Component, createResource, createMemo, Show } from 'solid-js'; import { getPatrolRunHistory, type PatrolRunRecord } from '@/api/patrol'; import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { formatRelativeTime } from '@/utils/format'; +import { formatTriggerReason } from '@/utils/patrolFormat'; import { isPatrolRunHealthy } from '@/utils/patrolRunPresentation'; import CheckCircleIcon from 'lucide-solid/icons/check-circle'; import AlertCircleIcon from 'lucide-solid/icons/alert-circle'; @@ -20,29 +21,6 @@ interface PatrolStatusBarProps { } export const PatrolStatusBar: Component = (props) => { - const formatTrigger = (reason?: string) => { - switch (reason) { - case 'scheduled': - return 'Scheduled'; - case 'manual': - return 'Manual'; - case 'startup': - return 'Startup'; - case 'alert_fired': - return 'Alert fired'; - case 'alert_cleared': - return 'Alert cleared'; - case 'anomaly': - return 'Anomaly'; - case 'user_action': - return 'User action'; - case 'config_changed': - return 'Config change'; - default: - return reason ? reason.replace(/_/g, ' ') : ''; - } - }; - const [runs] = createResource( () => props.refreshTrigger ?? 0, async (): Promise => { @@ -68,7 +46,7 @@ export const PatrolStatusBar: Component = (props) => { runsToday: todayRuns.length, newFindingsToday: todayRuns.reduce((sum, r) => sum + (r.new_findings || 0), 0), lastRunTime: lastRunTime ? formatRelativeTime(lastRunTime, { compact: true }) : null, - lastRunTrigger: formatTrigger(lastRun?.trigger_reason), + lastRunTrigger: formatTriggerReason(lastRun?.trigger_reason), isHealthy: isPatrolRunHealthy(lastRun?.status, lastRun?.error_count ?? 0), }; }); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index d7098212e..7efc5d60b 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -71,6 +71,12 @@ import resourceRelationshipPresentationSource from '@/utils/resourceRelationship import resourceCorrelationPresentationSource from '@/utils/resourceCorrelationPresentation.ts?raw'; import confidencePresentationSource from '@/utils/confidencePresentation.ts?raw'; import textPresentationSource from '@/utils/textPresentation.ts?raw'; +import messageItemSource from '@/components/AI/Chat/MessageItem.tsx?raw'; +import toolExecutionBlockSource from '@/components/AI/Chat/ToolExecutionBlock.tsx?raw'; +import aiChatSource from '@/components/AI/Chat/index.tsx?raw'; +import patrolStatusBarSource from '@/components/patrol/PatrolStatusBar.tsx?raw'; +import patrolFormatSource from '@/utils/patrolFormat.ts?raw'; +import aiFindingPresentationSource from '@/utils/aiFindingPresentation.ts?raw'; import resourcePolicyNormalizationSource from '@/utils/resourcePolicyNormalization.ts?raw'; import diskListSource from '@/components/Storage/DiskList.tsx?raw'; import useDiskListModelSource from '@/components/Storage/useDiskListModel.ts?raw'; @@ -148,7 +154,6 @@ import useUnifiedResourcesSource from '@/hooks/useUnifiedResources.ts?raw'; import findingsPanelSource from '@/components/AI/FindingsPanel.tsx?raw'; import exploreStatusBlockSource from '@/components/AI/Chat/ExploreStatusBlock.tsx?raw'; import aiExplorePresentationSource from '@/utils/aiExplorePresentation.ts?raw'; -import aiFindingPresentationSource from '@/utils/aiFindingPresentation.ts?raw'; import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw'; import discoveryPresentationSource from '@/utils/discoveryPresentation.ts?raw'; import mailGatewaySource from '@/components/PMG/MailGateway.tsx?raw'; @@ -1867,6 +1872,22 @@ describe('frontend resource type boundaries', () => { expect(resourceCorrelationPresentationSource).toContain('humanizeToken'); expect(resourceDetailDrawerSource).toContain('humanizeToken'); expect(textPresentationSource).toContain('humanizeToken'); + expect(textPresentationSource).toContain('formatIdentifierLabel'); + expect(messageItemSource).toContain('formatIdentifierLabel'); + expect(toolExecutionBlockSource).toContain('formatIdentifierLabel'); + expect(aiChatSource).toContain('formatIdentifierLabel'); + expect(patrolStatusBarSource).toContain('formatTriggerReason'); + expect(patrolFormatSource).toContain('formatIdentifierLabel'); + expect(aiFindingPresentationSource).toContain('formatIdentifierLabel'); + expect(messageItemSource).not.toContain("replace(/^pulse_/, '').replace(/_/g, ' ')"); + expect(toolExecutionBlockSource).not.toContain("replace(/^pulse_/, '').replace(/_/g, ' ')"); + expect(aiChatSource).not.toContain("replace(/^pulse_/, '').replace(/_/g, ' ')"); + expect(patrolStatusBarSource).not.toContain("replace(/_/g, ' ') : ''"); + expect(patrolFormatSource).not.toContain("replace(/_/g, ' ') : 'Unknown'"); + expect(aiFindingPresentationSource).not.toContain("replace(/_/g, ' ')"); + expect(patrolRunPresentationSource).toContain('formatIdentifierLabel'); + expect(patrolRunPresentationSource).not.toContain("normalized.replace(/_/g, ' ')"); + expect(patrolRunPresentationSource).not.toContain("normalized ? normalized.replace(/_/g, ' ') : 'unknown'"); expect(useUnifiedResourcesSource).toContain('normalizeResourcePolicyAISafeSummary('); expect(resourcePolicyNormalizationSource).toContain( 'normalizeResourcePolicyAISafeSummary', diff --git a/frontend-modern/src/utils/__tests__/textPresentation.test.ts b/frontend-modern/src/utils/__tests__/textPresentation.test.ts index bf44cd308..84c3c0b98 100644 --- a/frontend-modern/src/utils/__tests__/textPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/textPresentation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { humanizeToken } from '@/utils/textPresentation'; +import { formatIdentifierLabel, humanizeToken } from '@/utils/textPresentation'; describe('textPresentation', () => { it('humanizes underscore-separated tokens with a fallback', () => { @@ -13,4 +13,16 @@ describe('textPresentation', () => { expect(humanizeToken('IP', { preserveShortAllCaps: true })).toBe('IP'); expect(humanizeToken('vm')).toBe('Vm'); }); + + it('formats identifier labels without title-casing', () => { + expect(formatIdentifierLabel('pulse_get_container_status', { stripPrefix: 'pulse_' })).toBe( + 'get container status', + ); + expect(formatIdentifierLabel('some_reason')).toBe('some reason'); + expect(formatIdentifierLabel('pulse_very_long_unknown_tool_name', { + stripPrefix: 'pulse_', + maxLength: 12, + })).toBe('very long un'); + expect(formatIdentifierLabel(undefined, { fallback: 'Unknown' })).toBe('Unknown'); + }); }); diff --git a/frontend-modern/src/utils/aiFindingPresentation.ts b/frontend-modern/src/utils/aiFindingPresentation.ts index 530251c84..bb4a25abb 100644 --- a/frontend-modern/src/utils/aiFindingPresentation.ts +++ b/frontend-modern/src/utils/aiFindingPresentation.ts @@ -2,6 +2,7 @@ import type { UnifiedFinding } from '@/stores/aiIntelligence'; import type { ApprovalRequest } from '@/api/ai'; import type { InvestigationOutcome, InvestigationStatus } from '@/api/patrol'; import { isLivePendingApproval } from '@/utils/approvalState'; +import { formatIdentifierLabel } from '@/utils/textPresentation'; const DEFAULT_BADGE_CLASSES = 'border-border bg-surface-alt text-muted'; const DEFAULT_LOOP_STATE_CLASSES = 'border-border bg-surface-alt text-muted'; @@ -305,10 +306,11 @@ export const doesFindingNeedAttention = ( export const getFindingLoopStateBadgeClasses = (loopState: string): string => FINDING_LOOP_STATE_CLASSES[loopState] || DEFAULT_LOOP_STATE_CLASSES; -export const formatFindingLoopState = (loopState: string): string => loopState.replace(/_/g, ' '); +export const formatFindingLoopState = (loopState: string): string => + formatIdentifierLabel(loopState); export const formatFindingLifecycleType = (value: string): string => - FINDING_LIFECYCLE_LABELS[value] || value.replace(/_/g, ' '); + FINDING_LIFECYCLE_LABELS[value] || formatIdentifierLabel(value); export const getFindingResolutionReason = ( finding: Pick< diff --git a/frontend-modern/src/utils/patrolFormat.ts b/frontend-modern/src/utils/patrolFormat.ts index 1a2170057..21b69e4dd 100644 --- a/frontend-modern/src/utils/patrolFormat.ts +++ b/frontend-modern/src/utils/patrolFormat.ts @@ -1,4 +1,5 @@ // Pure formatting helpers for Patrol run data — no SolidJS dependencies. +import { formatIdentifierLabel } from '@/utils/textPresentation'; interface ModelInfo { id: string; @@ -52,7 +53,7 @@ export function formatTriggerReason(reason?: string): string { case 'config_changed': return 'Config change'; default: - return reason ? reason.replace(/_/g, ' ') : 'Unknown'; + return formatIdentifierLabel(reason, { fallback: 'Unknown' }); } } diff --git a/frontend-modern/src/utils/patrolRunPresentation.ts b/frontend-modern/src/utils/patrolRunPresentation.ts index e2d6a1ead..e764dd87e 100644 --- a/frontend-modern/src/utils/patrolRunPresentation.ts +++ b/frontend-modern/src/utils/patrolRunPresentation.ts @@ -1,4 +1,5 @@ import type { PatrolRunStatus } from '@/api/patrol'; +import { formatIdentifierLabel } from '@/utils/textPresentation'; export interface PatrolRunStatusPresentation { badgeClass: string; @@ -29,7 +30,7 @@ export function getPatrolRunStatusPresentation( case 'error': return { badgeClass: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', - label: normalized.replace(/_/g, ' '), + label: formatIdentifierLabel(normalized), }; case 'issues_found': return { @@ -44,7 +45,7 @@ export function getPatrolRunStatusPresentation( default: return { badgeClass: 'bg-surface-alt text-base-content', - label: normalized ? normalized.replace(/_/g, ' ') : 'unknown', + label: formatIdentifierLabel(normalized, { fallback: 'unknown' }), }; } } diff --git a/frontend-modern/src/utils/textPresentation.ts b/frontend-modern/src/utils/textPresentation.ts index 6c39b4fb5..7f2f1bcb1 100644 --- a/frontend-modern/src/utils/textPresentation.ts +++ b/frontend-modern/src/utils/textPresentation.ts @@ -3,6 +3,12 @@ export interface HumanizeTokenOptions { preserveShortAllCaps?: boolean; } +export interface IdentifierLabelOptions { + fallback?: string; + maxLength?: number; + stripPrefix?: string; +} + export function humanizeToken(value?: string, options?: HumanizeTokenOptions): string { const normalized = (value || '').trim(); if (!normalized) { @@ -17,3 +23,28 @@ export function humanizeToken(value?: string, options?: HumanizeTokenOptions): s .replace(/_/g, ' ') .replace(/\b\w/g, (char) => char.toUpperCase()); } + +export function formatIdentifierLabel( + value?: string, + options?: IdentifierLabelOptions, +): string { + let normalized = (value || '').trim(); + if (!normalized) { + return options?.fallback ?? ''; + } + + if (options?.stripPrefix && normalized.startsWith(options.stripPrefix)) { + normalized = normalized.slice(options.stripPrefix.length).trim(); + } + + if (!normalized) { + return options?.fallback ?? ''; + } + + const label = normalized.replace(/_/g, ' '); + if (options?.maxLength && options.maxLength > 0) { + return label.substring(0, options.maxLength); + } + + return label; +}