Skip to content
Open
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
55 changes: 55 additions & 0 deletions dashboard/src/api/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import client from './client';

export type MemoryLayer = 'WORKING' | 'EPISODIC' | 'SEMANTIC' | 'PROCEDURAL';

export type MemoryType =
| 'DECISION'
| 'CONSTRAINT'
| 'FAILURE'
| 'FIX'
| 'PREFERENCE'
| 'PROJECT_FACT'
| 'TASK_STATE'
| 'COMMAND_RESULT';

export interface RelevantMemoryItem {
id: string;
layer: MemoryLayer | null;
type: MemoryType | null;
title: string | null;
content: string | null;
scope: string | null;
tags: string[];
source: string | null;
confidence: number | null;
salience: number | null;
ttlDays: number | null;
createdAt: string | null;
updatedAt: string | null;
lastAccessedAt: string | null;
references: string[];
referenceCount: number;
}

export interface RelevantMemoryResponse {
items: RelevantMemoryItem[];
sessionId: string;
queryText: string;
}

export interface RelevantMemoryParams {
sessionId: string;
query?: string;
limit?: number;
}

export async function getRelevantMemories(params: RelevantMemoryParams): Promise<RelevantMemoryResponse> {
const { data } = await client.get<RelevantMemoryResponse>('/memory/relevant', {
params: {
sessionId: params.sessionId,
query: params.query,
limit: params.limit,
},
});
return data;
}
36 changes: 36 additions & 0 deletions dashboard/src/components/agentRun/ApprovalRequestCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { FiCheck, FiX } from 'react-icons/fi';
import type { ApprovalRequestViewModel } from './types';
import { formatTimeOfDay } from './agentRunFormat';

interface ApprovalRequestCardProps {
approval: ApprovalRequestViewModel;
onApprove?: () => void;
onReject?: () => void;
}

export default function ApprovalRequestCard({ approval, onApprove, onReject }: ApprovalRequestCardProps) {
return (
<section className="approval-card" role="alert" aria-label={`Approval request: ${approval.title}`}>
<header className="agent-card__header">
<span>{approval.title}</span>
<span className="incident-card__time">{formatTimeOfDay(approval.createdAt)}</span>
</header>
<p className="incident-card__body">{approval.description}</p>
<pre className="tool-row__details">{approval.prompt}</pre>
<div className="incident-card__actions">
{onApprove != null && (
<button type="button" className="agent-btn agent-btn--primary" onClick={onApprove}>
<FiCheck size={14} aria-hidden="true" />
<span>Approve</span>
</button>
)}
{onReject != null && (
<button type="button" className="agent-btn agent-btn--danger" onClick={onReject}>
<FiX size={14} aria-hidden="true" />
<span>Reject</span>
</button>
)}
</div>
</section>
);
}
33 changes: 33 additions & 0 deletions dashboard/src/components/agentRun/ArtifactCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { FiDownload, FiFileText } from 'react-icons/fi';
import type { ArtifactViewModel } from './types';

interface ArtifactCardProps {
artifact: ArtifactViewModel;
onOpen?: (id: string) => void;
}

export default function ArtifactCard({ artifact, onOpen }: ArtifactCardProps) {
return (
<article className="artifact-card" aria-label={`Artifact ${artifact.name}`}>
<span className="artifact-card__icon" aria-hidden="true"><FiFileText size={16} /></span>
<div className="artifact-card__body">
<span className="artifact-card__title">{artifact.name}</span>
{artifact.description != null && (
<span className="artifact-card__description">{artifact.description}</span>
)}
</div>
{artifact.href != null && (
<a className="agent-btn" href={artifact.href} target="_blank" rel="noreferrer">
<FiDownload size={14} aria-hidden="true" />
<span>Open</span>
</a>
)}
{artifact.href == null && onOpen != null && (
<button type="button" className="agent-btn" onClick={() => onOpen(artifact.id)}>
<FiDownload size={14} aria-hidden="true" />
<span>Open</span>
</button>
)}
</article>
);
}
31 changes: 31 additions & 0 deletions dashboard/src/components/agentRun/HarnessChatLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useState, type ReactNode } from 'react';
import TaskHeader from './TaskHeader';
import { useChatRunSummary } from './useChatRunSummary';

interface HarnessChatLayoutProps {
children: ReactNode;
}

export default function HarnessChatLayout({ children }: HarnessChatLayoutProps) {
const summary = useChatRunSummary();
const [titleOverride, setTitleOverride] = useState<string | null>(null);
const title = titleOverride ?? summary.title;

return (
<div className="harness-chat-layout">
<div className="harness-chat-layout__hero">
<TaskHeader
title={title}
status={summary.status}
startedAt={summary.startedAt}
durationMs={summary.durationMs}
stepCount={summary.stepCount}
onTitleChange={(next) => setTitleOverride(next)}
/>
</div>
<div className="harness-chat-layout__body">
{children}
</div>
</div>
);
}
39 changes: 39 additions & 0 deletions dashboard/src/components/agentRun/IncidentCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import IncidentCard from './IncidentCard';
import type { IncidentViewModel } from './types';

const incident: IncidentViewModel = {
id: 'inc-1',
runId: 'run-1',
severity: 'error',
title: 'LLM provider is temporarily unavailable',
message: 'Your task has been saved and will retry automatically in 5 minutes.',
code: 'llm.provider.circuit.open',
createdAt: '2026-04-29T10:43:00.000Z',
retryCountdownSeconds: 299,
taskSaved: true,
actions: [
{ id: 'retry_now', label: 'Retry now', kind: 'primary' },
{ id: 'switch_model', label: 'Switch model', kind: 'secondary' },
{ id: 'continue_manually', label: 'Continue manually', kind: 'secondary' },
{ id: 'open_logs', label: 'Open logs', kind: 'secondary' },
],
};

describe('IncidentCard', () => {
it('renders the title, message and code', () => {
const html = renderToStaticMarkup(<IncidentCard incident={incident} />);
expect(html).toContain('LLM provider is temporarily unavailable');
expect(html).toContain('llm.provider.circuit.open');
expect(html).toContain('Task saved automatically');
});

it('renders all recovery actions', () => {
const html = renderToStaticMarkup(<IncidentCard incident={incident} />);
expect(html).toContain('Retry now');
expect(html).toContain('Switch model');
expect(html).toContain('Continue manually');
expect(html).toContain('Open logs');
});
});
98 changes: 98 additions & 0 deletions dashboard/src/components/agentRun/IncidentCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { FiAlertTriangle, FiInfo, FiAlertOctagon } from 'react-icons/fi';
import type { IncidentActionViewModel, IncidentSeverity, IncidentViewModel } from './types';
import { formatCountdown, formatTimeOfDay } from './agentRunFormat';

type IncidentActionHandler = (actionId: IncidentActionViewModel['id']) => void;

interface IncidentCardProps {
incident: IncidentViewModel;
onAction?: IncidentActionHandler;
}

const SEVERITY_CLASS: Record<IncidentSeverity, string> = {
info: 'incident-card--info',
warning: 'incident-card--warning',
error: '',
critical: '',
};

function SeverityIcon({ severity }: { severity: IncidentSeverity }) {
if (severity === 'info') {
return <FiInfo size={16} aria-hidden="true" />;
}
if (severity === 'warning') {
return <FiAlertTriangle size={16} aria-hidden="true" />;
}
return <FiAlertOctagon size={16} aria-hidden="true" />;
}

function useCountdown(targetIso: string | undefined, fallbackSeconds?: number): number | null {
const [remaining, setRemaining] = useState<number | null>(() => {
if (targetIso == null && fallbackSeconds == null) {
return null;
}
if (targetIso != null) {
const diffMs = new Date(targetIso).getTime() - Date.now();
return Math.max(0, Math.floor(diffMs / 1000));
}
return fallbackSeconds ?? null;
});

// Tick every second while the countdown has time remaining; clean up on unmount.
useEffect(() => {
if (remaining == null || remaining <= 0) {
return undefined;
}
const id = window.setInterval(() => {
setRemaining((prev) => {
if (prev == null) {
return null;
}
if (targetIso != null) {
const diffMs = new Date(targetIso).getTime() - Date.now();
return Math.max(0, Math.floor(diffMs / 1000));
}
return Math.max(0, prev - 1);
});
}, 1000);
return () => window.clearInterval(id);
}, [remaining, targetIso]);

return remaining;
}

export default function IncidentCard({ incident, onAction }: IncidentCardProps) {
const remaining = useCountdown(incident.retryAt, incident.retryCountdownSeconds);
return (
<section
className={`incident-card ${SEVERITY_CLASS[incident.severity]}`.trim()}
aria-label={`${incident.title} incident`}
role="alert"
>
<div className="incident-card__header">
<SeverityIcon severity={incident.severity} />
<span className="incident-card__title">{incident.title}</span>
<span className="incident-card__time">{formatTimeOfDay(incident.createdAt)}</span>
</div>
<div className="incident-card__body">{incident.message}</div>
<div className="incident-card__meta">
{incident.code != null && <span>Reason: <code>{incident.code}</code></span>}
{incident.taskSaved && <span>Task saved automatically</span>}
{remaining != null && <span>Retry in: {formatCountdown(remaining)}</span>}
</div>
<div className="incident-card__actions">
{incident.actions.map((action) => (
<button
key={action.id}
type="button"
className={`agent-btn${action.kind === 'primary' ? ' agent-btn--primary' : ''}${action.kind === 'danger' ? ' agent-btn--danger' : ''}`}
onClick={() => onAction?.(action.id)}
>
{action.label}
</button>
))}
</div>
</section>
);
}
48 changes: 48 additions & 0 deletions dashboard/src/components/agentRun/PlanBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import PlanBlock from './PlanBlock';
import type { PlanViewModel } from './types';

const plan: PlanViewModel = {
id: 'plan-1',
runId: 'run-1',
version: 1,
updatedAt: '2026-04-29T10:31:00.000Z',
steps: [
{ id: 's1', index: 1, title: 'Inspect current optimizer config', status: 'completed', relatedToolCallIds: [] },
{ id: 's2', index: 2, title: 'Update switching logic', status: 'completed', relatedToolCallIds: [] },
{ id: 's3', index: 3, title: 'Run optimizer backtest', status: 'running', relatedToolCallIds: [] },
{ id: 's4', index: 4, title: 'Validate results and compare', status: 'pending', relatedToolCallIds: [] },
{ id: 's5', index: 5, title: 'Save report and summary', status: 'pending', relatedToolCallIds: [] },
],
};

describe('PlanBlock', () => {
it('renders all steps with their status labels', () => {
const html = renderToStaticMarkup(<PlanBlock plan={plan} currentStepIndex={3} />);
expect(html).toContain('Inspect current optimizer config');
expect(html).toContain('Run optimizer backtest');
expect(html).toContain('In progress');
expect(html).toContain('5 steps');
});

it('shows Approve action only when needsApproval', () => {
const without = renderToStaticMarkup(<PlanBlock plan={plan} onApprove={() => undefined} />);
expect(without).not.toContain('Approve');

const withApproval = renderToStaticMarkup(
<PlanBlock plan={plan} needsApproval onApprove={() => undefined} />,
);
expect(withApproval).toContain('Approve');
});

it('shows Pause only when run is active', () => {
const idle = renderToStaticMarkup(<PlanBlock plan={plan} onPause={() => undefined} />);
expect(idle).not.toContain('Pause');

const running = renderToStaticMarkup(
<PlanBlock plan={plan} isRunActive onPause={() => undefined} />,
);
expect(running).toContain('Pause');
});
});
Loading
Loading