Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/lib/feature-gates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { getUnavailableReason } from './feature-gates'

describe('getUnavailableReason', () => {
it('points the sessions copy at the direct sessions endpoint', () => {
const message = getUnavailableReason('sessions')

expect(message).toContain('/api/sessions')
expect(message).not.toContain('/api/gateway-status')
})

it('uses real Workspace API routes for non-session features', () => {
expect(getUnavailableReason('config')).toContain('/api/claude-config')
expect(getUnavailableReason('jobs')).toContain('/api/claude-jobs')
expect(getUnavailableReason('memory')).toContain('/api/memory/list')
})
})
17 changes: 16 additions & 1 deletion src/lib/feature-gates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ const FEATURE_LABELS: Record<EnhancedFeature, string> = {
kanban: 'Kanban (Hermes plugin)',
}

const FEATURE_PROBES: Record<EnhancedFeature, Array<string>> = {
sessions: ['/api/sessions'],
skills: ['/api/gateway-status', '/api/skills'],
memory: ['/api/gateway-status', '/api/memory/list'],
config: ['/api/gateway-status', '/api/claude-config'],
jobs: ['/api/gateway-status', '/api/claude-jobs'],
mcp: ['/api/gateway-status', '/api/mcp'],
mcpFallback: ['/api/gateway-status', '/api/mcp'],
kanban: ['/api/gateway-status', '/api/swarm-kanban'],
}

function normalizeFeature(
feature: EnhancedFeature | string,
): EnhancedFeature | null {
Expand Down Expand Up @@ -52,7 +63,11 @@ export function getFeatureLabel(feature: EnhancedFeature | string): string {
export function getUnavailableReason(
feature: EnhancedFeature | string,
): string {
return `${getFeatureLabel(feature)} requires a Hermes gateway that exposes the extended APIs. Check that Hermes Agent is installed and running with \`hermes gateway run\`.`
const normalized = normalizeFeature(feature)
const probes = normalized
? FEATURE_PROBES[normalized].join(' or ')
: '/api/gateway-status'
return `${getFeatureLabel(feature)} is not reachable through the local Hermes Workspace probes yet. Verify ${probes} before starting another gateway; if those endpoints pass, refresh or reprobe the Workspace UI.`
}

export function createCapabilityUnavailablePayload(
Expand Down
37 changes: 24 additions & 13 deletions src/screens/dashboard/dashboard-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { OperatorTipCard } from './components/operator-tip-card'
import { WidgetShell } from './components/widget-shell'
import { EditModePanel } from './components/edit-mode-panel'
import { useDashboardLayout } from './lib/use-dashboard-layout'
import { normalizeDashboardSessionsPayload } from './lib/sessions-query'
import {
Area,
AreaChart,
Expand Down Expand Up @@ -639,7 +640,6 @@ function SessionRow({

export function DashboardScreen() {
const navigate = useNavigate()
const sessionsAvailable = useFeatureAvailable('sessions')
const skillsAvailable = useFeatureAvailable('skills')
const sessionsQuery = useQuery({
// Use a dedicated query key — NOT chatQueryKeys.sessions — to avoid
Expand All @@ -648,25 +648,32 @@ export function DashboardScreen() {
// Also use the workspace proxy (/api/sessions) rather than the server-side
// listSessions() — the latter calls the gateway via CLAUDE_API which is
// only available server-side and returns nothing when called from the client.
// Do not gate this direct proof behind /api/gateway-status. That probe can
// be stale/loading while /api/sessions already works, which made the
// dashboard show a bogus “Enhanced API required” warning even though
// sessions were healthy.
queryKey: ['dashboard', 'sessions'],
queryFn: async () => {
const res = await fetch('/api/sessions?limit=200&offset=0')
if (!res.ok) return [] as Array<Record<string, unknown>>
const data = (await res.json()) as {
sessions?: Array<Record<string, unknown>>
if (!res.ok) {
throw new Error(`Sessions API returned HTTP ${res.status}`)
}
return data.sessions ?? []
const data = await res.json()
return normalizeDashboardSessionsPayload(data)
},
staleTime: 10_000,
refetchInterval: 30_000,
enabled: sessionsAvailable,
retry: 1,
})

const sessionsResult = sessionsQuery.data

// Raw rows from the sessions endpoint. Used both for hero stats
// (count/tokens) and for the SessionsIntelligenceCard below.
const rawSessions = (sessionsQuery.data ?? []) as Array<
Record<string, unknown>
>
const rawSessions = sessionsResult?.sessions ?? []
const sessionsUnavailable = Boolean(sessionsResult?.unavailable)
const sessionsUnavailableMessage =
sessionsResult?.message ?? getUnavailableReason('sessions')

// Adapter shape kept for the legacy fallbacks that still reference
// ClaudeSession (HeroMetrics fallback path, etc.).
Expand Down Expand Up @@ -1142,13 +1149,17 @@ export function DashboardScreen() {
{layout.isVisible('sessions_intelligence') ? (
<div className="flex min-h-0 flex-1 flex-col">
<WidgetShell id="sessions_intelligence" layout={layout}>
{sessionsAvailable ? (
<SessionsIntelligenceCard sessions={sessionRows} />
) : (
{sessionsQuery.isError || sessionsUnavailable ? (
<UnavailableWidget
title="Recent Sessions"
description={getUnavailableReason('sessions')}
description={
sessionsQuery.isError
? getUnavailableReason('sessions')
: sessionsUnavailableMessage
}
/>
) : (
<SessionsIntelligenceCard sessions={sessionRows} />
)}
</WidgetShell>
</div>
Expand Down
42 changes: 42 additions & 0 deletions src/screens/dashboard/lib/sessions-query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { normalizeDashboardSessionsPayload } from './sessions-query'

describe('normalizeDashboardSessionsPayload', () => {
it('keeps working session responses available', () => {
const result = normalizeDashboardSessionsPayload({
sessions: [{ id: 'session-1' }],
})

expect(result).toEqual({
sessions: [{ id: 'session-1' }],
unavailable: false,
message: undefined,
})
})

it('marks source:unavailable responses as unavailable', () => {
const result = normalizeDashboardSessionsPayload({
sessions: [],
source: 'unavailable',
message: 'Sessions are unavailable',
})

expect(result).toEqual({
sessions: [],
unavailable: true,
message: 'Sessions are unavailable',
})
})

it('marks capability_unavailable responses as unavailable', () => {
const result = normalizeDashboardSessionsPayload({
code: 'capability_unavailable',
})

expect(result).toEqual({
sessions: [],
unavailable: true,
message: undefined,
})
})
})
23 changes: 23 additions & 0 deletions src/screens/dashboard/lib/sessions-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface DashboardSessionsPayload {
sessions?: Array<Record<string, unknown>>
source?: string
code?: string
message?: string
}

export interface DashboardSessionsResult {
sessions: Array<Record<string, unknown>>
unavailable: boolean
message?: string
}

export function normalizeDashboardSessionsPayload(
data: DashboardSessionsPayload,
): DashboardSessionsResult {
return {
sessions: data.sessions ?? [],
unavailable:
data.source === 'unavailable' || data.code === 'capability_unavailable',
message: typeof data.message === 'string' ? data.message : undefined,
}
}