feat(frontend): add Chinese (zh) i18n support (#49)

This commit is contained in:
Xu Lingrui 2026-04-03 22:34:47 +08:00 committed by GitHub
parent 565f09f1c7
commit e80c70e9c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 879 additions and 202 deletions

View file

@ -9,9 +9,11 @@
"version": "0.1.0",
"dependencies": {
"axios": "^1.7.9",
"i18next": "^26.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.25.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
@ -272,6 +274,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -2297,6 +2308,46 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.0.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz",
"integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/index-array-by": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
@ -2916,6 +2967,33 @@
"react": "*"
}
},
"node_modules/react-i18next": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz",
"integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.0.1",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -3319,7 +3397,7 @@
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -3360,6 +3438,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -3473,6 +3560,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -10,9 +10,11 @@
},
"dependencies": {
"axios": "^1.7.9",
"i18next": "^26.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.25.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.1.1"
},
"devDependencies": {

View file

@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import i18n from '../i18n';
interface Props {
children: ReactNode;
@ -21,7 +22,6 @@ export class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error to reporting service
if (import.meta.env.DEV) {
console.error('Error caught by boundary:', error, errorInfo);
}
@ -29,25 +29,26 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
const t = i18n.t.bind(i18n);
return (
this.props.fallback || (
<div className="min-h-screen flex items-center justify-center bg-[color:var(--color-bg-page)]">
<div className="text-center">
<h1 className="text-2xl font-bold text-[color:var(--color-danger)] mb-4">
Something went wrong
{t('errorBoundary.title')}
</h1>
<p className="text-[color:var(--color-muted)] mb-6">
An unexpected error occurred
{t('errorBoundary.message')}
</p>
<button
onClick={() => window.location.href = '/dashboard'}
className="btn-primary"
>
Go to Dashboard
{t('errorBoundary.goToDashboard')}
</button>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 text-left text-xs text-[color:var(--color-muted)]">
<summary>Error details</summary>
<summary>{t('errorBoundary.details')}</summary>
<pre className="mt-2 p-4 bg-[color:var(--color-surface)] overflow-auto">
{this.state.error.stack}
</pre>

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { DiffFile, DiffLine } from '../../utils/diffParser';
interface DiffViewerProps {
@ -124,6 +125,7 @@ function buildSplitRows(lines: DiffLine[], header: string): SplitDiffRow[] {
}
export default function DiffViewer({ files }: DiffViewerProps) {
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const renderableFiles = useMemo(
() => files.filter((file) => file.hunks.some((hunk) => hunk.lines.length > 0)),
@ -135,7 +137,7 @@ export default function DiffViewer({ files }: DiffViewerProps) {
}, [renderableFiles]);
if (renderableFiles.length === 0) {
return <p className="text-[color:var(--color-muted)] text-sm">No files in diff</p>;
return <p className="text-[color:var(--color-muted)] text-sm">{t('diffViewer.noFiles')}</p>;
}
const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0;
@ -216,8 +218,8 @@ export default function DiffViewer({ files }: DiffViewerProps) {
return (
<div key={`${activeFile.path}-hunk-${hunkIdx}`}>
<div className="sticky top-0 z-10 grid grid-cols-[1fr_1fr] border-y border-[color:var(--color-ink)] bg-[#CBCADB] px-3 py-1.5 text-[color:var(--color-muted)] select-none">
<div>Old</div>
<div>New</div>
<div>{t('diffViewer.old')}</div>
<div>{t('diffViewer.new')}</div>
</div>
<div className="sticky top-[29px] z-10 border-b border-[color:var(--color-border-dark)] bg-[color:var(--color-surface)] px-3 py-1 text-[color:var(--color-muted)] select-none">
{hunk.header}

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ForceGraph2D from 'react-force-graph-2d';
import type { SkillGraphNode } from '../../hooks/useSkillEvolutionGraphData';
@ -25,6 +26,7 @@ export default function SkillEvolutionGraph({
onNodeClick,
onBackgroundClick,
}: SkillEvolutionGraphProps) {
const { t } = useTranslation();
const graphContainerRef = useRef<HTMLDivElement>(null);
const fgRef = useRef<any>(null);
const [graphDim, setGraphDim] = useState({ width: 0, height: 0 });
@ -180,7 +182,7 @@ export default function SkillEvolutionGraph({
}, []);
if (graphData.nodes.length === 0) {
return <div className="text-sm text-muted p-4">No lineage graph data.</div>;
return <div className="text-sm text-muted p-4">{t('graph.noGraphData')}</div>;
}
return (
@ -197,9 +199,9 @@ export default function SkillEvolutionGraph({
const graphNode = node as SkillGraphNode;
return [
graphNode.name,
`score: ${graphNode.score.toFixed(1)}`,
`generation: ${graphNode.generation}`,
`origin: ${graphNode.origin}`,
t('graph.tooltipScore', { value: graphNode.score.toFixed(1) }),
t('graph.tooltipGeneration', { value: graphNode.generation }),
t('graph.tooltipOrigin', { value: graphNode.origin }),
].join('\n');
}}
onNodeClick={(node) => {

View file

@ -1,6 +1,7 @@
import { useLayoutEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { SkillDetail } from '../../api';
import { parseDiff } from '../../utils/diffParser';
import EmptyState from '../EmptyState';
@ -72,6 +73,7 @@ function lockScroll() {
}
export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVersionDrawerProps) {
const { t } = useTranslation();
const closeButtonRef = useRef<HTMLButtonElement>(null);
const rawDiff = skill?.lineage.content_diff ?? '';
@ -135,16 +137,16 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
<div className="drawer-scroll flex h-full w-full flex-col overflow-hidden overscroll-contain">
<header className="p-4 border-b-2 border-[color:var(--color-border)] flex items-start justify-between gap-3 shrink-0">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted">Skill Version</p>
<p className="text-xs uppercase tracking-wide text-muted">{t('drawer.skillVersion')}</p>
<h2 id="skill-version-drawer-title" className="font-bold text-lg truncate">{skill.name}</h2>
<p className="text-xs text-muted font-mono break-all">{skill.skill_id}</p>
</div>
<div className="flex items-center gap-2">
<Link to={`/skills/${encodeURIComponent(skill.skill_id)}`} className="btn-outline-ink text-sm">
Open as main
{t('drawer.openAsMain')}
</Link>
<button type="button" onClick={onClose} ref={closeButtonRef} className="btn-outline-ink text-sm">
Close
{t('common.close')}
</button>
</div>
</header>
@ -153,13 +155,13 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 min-w-0">
<div className="text-xs uppercase tracking-[0.16em] text-muted">Version Summary</div>
<div className="text-sm text-muted">{skill.description || 'No description available for this version.'}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.versionSummary')}</div>
<div className="text-sm text-muted">{skill.description || t('drawer.noDescriptionAvailable')}</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="tag px-2 py-1">{skill.category}</span>
<span className="tag px-2 py-1">{skill.origin}</span>
<span className="tag px-2 py-1">gen {skill.generation}</span>
<span className="tag px-2 py-1">{skill.is_active ? 'active' : 'inactive'}</span>
<span className="tag px-2 py-1">{t('drawer.gen', { generation: skill.generation })}</span>
<span className="tag px-2 py-1">{skill.is_active ? t('common.active') : t('common.inactive')}</span>
{skill.tags.map((tag) => (
<span key={tag} className="tag px-2 py-1">{tag}</span>
))}
@ -167,67 +169,67 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
</div>
<div className="text-right shrink-0">
<div className="text-4xl font-bold font-serif leading-none">{skill.score.toFixed(1)}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">version score</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('drawer.versionScore')}</div>
</div>
</div>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Metrics</div>
<h3 className="text-xl font-bold font-serif mt-1">Execution quality</h3>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.metrics')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.executionQuality')}</h3>
</div>
<div className="space-y-4">
<ProgressBar label="Effective rate" value={skill.effective_rate} colorClass="bg-primary" />
<ProgressBar label="Completion rate" value={skill.completion_rate} colorClass="bg-accent" />
<ProgressBar label="Applied rate" value={skill.applied_rate} colorClass="bg-teal" />
<ProgressBar label="Fallback rate" value={skill.fallback_rate} colorClass="bg-danger" />
<ProgressBar label={t('drawer.effectiveRate')} value={skill.effective_rate} colorClass="bg-primary" />
<ProgressBar label={t('drawer.completionRate')} value={skill.completion_rate} colorClass="bg-accent" />
<ProgressBar label={t('drawer.appliedRate')} value={skill.applied_rate} colorClass="bg-teal" />
<ProgressBar label={t('drawer.fallbackRate')} value={skill.fallback_rate} colorClass="bg-danger" />
</div>
<div className="grid grid-cols-2 gap-3 text-sm text-muted">
<div><div className="font-bold text-ink">Selections</div><div>{skill.total_selections}</div></div>
<div><div className="font-bold text-ink">Applied</div><div>{skill.total_applied}</div></div>
<div><div className="font-bold text-ink">Completions</div><div>{skill.total_completions}</div></div>
<div><div className="font-bold text-ink">Fallbacks</div><div>{skill.total_fallbacks}</div></div>
<div><div className="font-bold text-ink">{t('drawer.selectionsLabel')}</div><div>{skill.total_selections}</div></div>
<div><div className="font-bold text-ink">{t('drawer.appliedLabel')}</div><div>{skill.total_applied}</div></div>
<div><div className="font-bold text-ink">{t('drawer.completionsLabel')}</div><div>{skill.total_completions}</div></div>
<div><div className="font-bold text-ink">{t('drawer.fallbacksLabel')}</div><div>{skill.total_fallbacks}</div></div>
</div>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 text-sm space-y-2">
<h3 className="font-bold">Version Metadata</h3>
<p><strong>Origin:</strong> {skill.origin}</p>
<p><strong>Generation:</strong> {skill.generation}</p>
<p><strong>Visibility:</strong> {skill.visibility}</p>
<p><strong>Created:</strong> {formatDate(skill.lineage.created_at)}</p>
<p><strong>First seen:</strong> {formatDate(skill.first_seen)}</p>
<p><strong>Last updated:</strong> {formatDate(skill.last_updated)}</p>
<p><strong>Skill path:</strong> <span className="break-all">{skill.path || 'Unavailable'}</span></p>
<p><strong>Skill dir:</strong> <span className="break-all">{skill.skill_dir || 'Unavailable'}</span></p>
<p><strong>Parent IDs:</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : 'None'}</p>
<p><strong>Change summary:</strong> {skill.lineage.change_summary || 'None'}</p>
<p><strong>Effective score:</strong> {formatPercent(skill.effective_rate)}</p>
<h3 className="font-bold">{t('drawer.versionMetadata')}</h3>
<p><strong>{t('drawer.origin')}</strong> {skill.origin}</p>
<p><strong>{t('drawer.generation')}</strong> {skill.generation}</p>
<p><strong>{t('drawer.visibility')}</strong> {skill.visibility}</p>
<p><strong>{t('drawer.created')}</strong> {formatDate(skill.lineage.created_at)}</p>
<p><strong>{t('drawer.firstSeen')}</strong> {formatDate(skill.first_seen)}</p>
<p><strong>{t('drawer.lastUpdated')}</strong> {formatDate(skill.last_updated)}</p>
<p><strong>{t('drawer.skillPath')}</strong> <span className="break-all">{skill.path || t('common.unavailable')}</span></p>
<p><strong>{t('drawer.skillDir')}</strong> <span className="break-all">{skill.skill_dir || t('common.unavailable')}</span></p>
<p><strong>{t('drawer.parentIds')}</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : t('common.none')}</p>
<p><strong>{t('drawer.changeSummary')}</strong> {skill.lineage.change_summary || t('common.none')}</p>
<p><strong>{t('drawer.effectiveScore')}</strong> {formatPercent(skill.effective_rate)}</p>
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Diff</div>
<h3 className="text-xl font-bold font-serif mt-1">Content diff</h3>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.diff')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.contentDiff')}</h3>
</div>
{isOversizedDiff ? (
<EmptyState title="Diff too large" description="This version has a very large content diff, so the inline viewer is disabled." />
<EmptyState title={t('drawer.diffTooLarge')} description={t('drawer.diffTooLargeDesc')} />
) : canShowDiff ? (
diffFiles.length > 0 ? (
<DiffViewer files={diffFiles} />
) : (
<EmptyState title="Diff unavailable" description="This version has a diff payload, but it could not be parsed as a unified diff." />
<EmptyState title={t('drawer.diffUnavailable')} description={t('drawer.diffUnavailableDesc')} />
)
) : (
<EmptyState title="No content diff" description="This version does not have a stored content diff." />
<EmptyState title={t('drawer.noContentDiff')} description={t('drawer.noContentDiffDesc')} />
)}
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Source</div>
<h3 className="text-xl font-bold font-serif mt-1">SKILL.md preview</h3>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.source')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.skillMdPreview')}</h3>
</div>
{sourcePreview ? (
<div className="space-y-3">
@ -235,14 +237,14 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
<pre className="field-surface p-4 text-xs overflow-auto max-h-[320px] whitespace-pre-wrap">{sourcePreview.content}</pre>
</div>
) : (
<EmptyState title="Source unavailable" description="This version points to a missing or unreadable SKILL.md path." />
<EmptyState title={t('drawer.sourceUnavailable')} description={t('drawer.sourceUnavailableDesc')} />
)}
</section>
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Analyses</div>
<h3 className="text-xl font-bold font-serif mt-1">Recent execution analyses</h3>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.analyses')}</div>
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.recentAnalyses')}</h3>
</div>
{skill.recent_analyses.length > 0 ? (
<div className="space-y-3">
@ -252,15 +254,19 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
<div className="font-bold truncate">{analysis.task_id}</div>
<div className="text-xs text-muted">{formatDate(analysis.timestamp)}</div>
</div>
<div className="text-sm text-muted">{truncate(analysis.execution_note || 'No execution note', 220)}</div>
<div className="text-sm text-muted">{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}</div>
<div className="text-xs text-muted">
completed: {analysis.task_completed ? 'yes' : 'no'} · tool issues: {analysis.tool_issues.length} · suggestions: {analysis.evolution_suggestions.length}
{t('drawer.analysisCompleted', {
value: analysis.task_completed ? t('common.yes') : t('common.no'),
toolIssues: analysis.tool_issues.length,
suggestions: analysis.evolution_suggestions.length,
})}
</div>
</div>
))}
</div>
) : (
<EmptyState title="No analyses yet" description="Execution analyses will appear after recorded task runs are persisted into SQLite." />
<EmptyState title={t('drawer.noAnalysesYet')} description={t('drawer.noAnalysesDesc')} />
)}
</section>
</main>

View file

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
interface SkillVersionFilterBarProps {
originFilter: string;
onOriginFilterChange: (value: string) => void;
@ -15,16 +17,18 @@ export default function SkillVersionFilterBar({
allOrigins,
allTags,
}: SkillVersionFilterBarProps) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Origin:</label>
<label className="text-sm font-medium">{t('filter.origin')}</label>
<select
value={originFilter}
onChange={(event) => onOriginFilterChange(event.target.value)}
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
>
<option value="all">All Origins</option>
<option value="all">{t('filter.allOrigins')}</option>
{allOrigins.map((origin) => (
<option key={origin} value={origin}>{origin}</option>
))}
@ -32,13 +36,13 @@ export default function SkillVersionFilterBar({
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Tags:</label>
<label className="text-sm font-medium">{t('filter.tags')}</label>
<select
value={tagFilter}
onChange={(event) => onTagFilterChange(event.target.value)}
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
>
<option value="all">All Tags</option>
<option value="all">{t('filter.allTags')}</option>
{allTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}

253
frontend/src/i18n/en.json Normal file
View file

@ -0,0 +1,253 @@
{
"nav": {
"dashboard": "Dashboard",
"skills": "Skills",
"workflows": "Workflows"
},
"common": {
"loading": "Loading…",
"yes": "yes",
"no": "no",
"none": "None",
"unavailable": "Unavailable",
"unknown": "Unknown",
"close": "Close",
"score": "score",
"success": "success",
"active": "active",
"inactive": "inactive",
"noDescription": "No description",
"steps_one": "{{count}} step",
"steps_other": "{{count}} steps",
"agentActions_one": "{{count}} agent action",
"agentActions_other": "{{count}} agent actions",
"tags": "+{{count}} tags"
},
"langSwitch": {
"label": "Language"
},
"errorBoundary": {
"title": "Something went wrong",
"message": "An unexpected error occurred",
"goToDashboard": "Go to Dashboard",
"details": "Error details"
},
"dashboard": {
"title": "Dashboard",
"loadingDashboard": "Loading dashboard…",
"failedToLoad": "Failed to load overview",
"dashboardUnavailable": "Dashboard unavailable",
"totalSkills": "Total Skills",
"activeHint": "Active: {{count}}",
"avgSkillScore": "Average Skill Score",
"avgScoreHint": "Primary metric = effective rate × 100",
"workflowSessions": "Workflow Sessions",
"localRepo": "local repo",
"workspace": "workspace",
"recordedUnder": "Recorded under {{location}}",
"workflowSuccess": "Workflow Success",
"avgSuccessHint": "Average session success rate",
"health": "Health",
"runtimeSnapshot": "Runtime snapshot",
"status": "Status",
"dbPath": "DB Path",
"workflowCount": "Workflow Count",
"builtFrontend": "Built Frontend",
"skillsSection": "Skills",
"topScoredSkills": "Top scored skills",
"noSkillsYet": "No skills yet",
"noSkillsDesc": "Run OpenSpace tasks or sync skills into the local registry first.",
"effective": "effective {{value}}",
"applied": "applied {{value}}",
"selections": "selections {{count}}",
"workflowsSection": "Workflows",
"recentSessions": "Recent sessions",
"noWorkflowSessions": "No workflow sessions",
"noWorkflowDesc": "Recordings will appear after a task is executed with recording enabled."
},
"skills": {
"title": "Skill classes",
"searchPlaceholder": "Search by name, id, description, tag, or origin",
"sortByScore": "Sort by best score",
"sortByUpdated": "Sort by updated time",
"sortByName": "Sort by name",
"skillClasses": "Skill Classes",
"versionsHint": "Versions: {{count}}",
"activeVersions": "Active Versions",
"withActivity": "With activity: {{count}}",
"avgBestScore": "Average Best Score",
"bestNodeScoreHint": "Best node score per class",
"selections": "Selections",
"completionsHint": "Completions: {{count}}",
"loadingSkills": "Loading skills…",
"failedToLoad": "Failed to load skills",
"noSkillsMatch": "No skills match",
"noSkillsMatchDesc": "Try another keyword, or execute tasks so new skill telemetry lands in SQLite.",
"bestScore": "best score",
"noClassDescription": "No class description",
"versions": "{{count}} versions",
"active": "{{count}} active",
"selectionsCount": "{{count}} selections"
},
"skillDetail": {
"loadingDetail": "Loading skill detail…",
"failedToLoad": "Failed to load skill class",
"skillNotFound": "Skill not found",
"backToSkills": "← Back to Skills",
"anchoredOn": "Skill class anchored on {{id}}",
"skillClass": "Skill Class",
"evolutionOverview": "Evolution overview",
"activeTip": "active tip",
"inactiveAnchor": "inactive anchor",
"bestVersionScore": "best version score",
"skillDirectory": "Skill directory",
"latestVersionCreated": "Latest version created",
"representativeVersion": "Representative version",
"representativeUpdate": "Representative update",
"versions": "Versions",
"maxGeneration": "Max generation {{count}}",
"activeVersions": "Active Versions",
"originsCount": "Origins: {{count}}",
"averageScore": "Average Score",
"acrossAllVersions": "Across all versions in this lineage",
"selections": "Selections",
"representativeScore": "Representative score {{score}}",
"evolutionGraph": "Evolution Graph",
"versionLineage": "Version lineage",
"loadingDrawer": "Loading version drawer…",
"noLineageGraph": "No lineage graph",
"noLineageGraphDesc": "This skill does not yet have lineage data to visualize."
},
"workflows": {
"title": "Recorded sessions",
"searchPlaceholder": "Search by task name or instruction",
"workflowSessions": "Workflow Sessions",
"scannedFrom": "Scanned from logs/recordings and logs/trajectories",
"averageSuccess": "Average Success",
"meanSuccessRate": "Mean success rate across sessions",
"loadingWorkflows": "Loading workflows…",
"failedToLoad": "Failed to load workflows",
"noSessions": "No workflow sessions",
"noSessionsDesc": "Run `openspace` with recording enabled, then refresh this page.",
"more": "+{{count}} more"
},
"workflowDetail": {
"loadingDetail": "Loading workflow detail…",
"failedToLoad": "Failed to load workflow",
"workflowNotFound": "Workflow not found",
"backToWorkflows": "← Back to Workflows",
"workflowDetail": "Workflow detail",
"skillsSelected_one": "{{count}} skill selected",
"skillsSelected_other": "{{count}} skills selected",
"mergedEvent_one": "{{count}} merged event",
"mergedEvent_other": "{{count}} merged events",
"runDescription": "{{status}} run with {{iterations}} and {{actions}}.",
"started": "Started",
"duration": "Duration",
"stepsLabel": "Steps",
"latestEvent": "Latest event",
"successRate": "Success rate",
"successRateHint_one": "{{count}} successful iteration out of {{iterations}}",
"successRateHint_other": "{{count}} successful iterations out of {{iterations}}",
"iterations": "Iterations",
"totalRuntime": "{{duration}} total runtime",
"activeBackends": "Active backends",
"mostActive": "Most active {{backend}} · {{count}} events",
"noRecordedActivity": "No recorded tool activity",
"timelineEvents": "Timeline events",
"agentToolHint": "{{agentCount}} agent actions · {{toolCount}} tool events",
"noTimelineData": "No timeline data",
"noTimelineDesc": "This session does not yet contain trajectory or agent action records.",
"rawEventJson": "Raw event JSON",
"selection": "Selection",
"selectedSkills": "Selected skills",
"noSelectedSkills": "No selected skills",
"noSelectedSkillsDesc": "No skills were selected or recorded for this run.",
"session": "Session",
"overview": "Overview",
"taskId": "Task ID",
"runtime": "Runtime",
"window": "Window",
"ended": "Ended {{date}}",
"selectionMethod": "Selection method",
"notRecorded": "Not recorded",
"enabledBackends": "Enabled backends",
"backendActivity": "Backend activity",
"iteration_one": "{{count}} iteration",
"iteration_other": "{{count}} iterations",
"step_one": "{{count}} step",
"step_other": "{{count}} steps",
"agentAction_one": "{{count}} agent action",
"agentAction_other": "{{count}} agent actions",
"successfulIteration_one": "{{count}} successful iteration",
"successfulIteration_other": "{{count}} successful iterations"
},
"drawer": {
"skillVersion": "Skill Version",
"openAsMain": "Open as main",
"versionSummary": "Version Summary",
"noDescriptionAvailable": "No description available for this version.",
"gen": "gen {{generation}}",
"versionScore": "version score",
"metrics": "Metrics",
"executionQuality": "Execution quality",
"effectiveRate": "Effective rate",
"completionRate": "Completion rate",
"appliedRate": "Applied rate",
"fallbackRate": "Fallback rate",
"selectionsLabel": "Selections",
"appliedLabel": "Applied",
"completionsLabel": "Completions",
"fallbacksLabel": "Fallbacks",
"versionMetadata": "Version Metadata",
"origin": "Origin:",
"generation": "Generation:",
"visibility": "Visibility:",
"created": "Created:",
"firstSeen": "First seen:",
"lastUpdated": "Last updated:",
"skillPath": "Skill path:",
"skillDir": "Skill dir:",
"parentIds": "Parent IDs:",
"changeSummary": "Change summary:",
"effectiveScore": "Effective score:",
"diff": "Diff",
"contentDiff": "Content diff",
"diffTooLarge": "Diff too large",
"diffTooLargeDesc": "This version has a very large content diff, so the inline viewer is disabled.",
"diffUnavailable": "Diff unavailable",
"diffUnavailableDesc": "This version has a diff payload, but it could not be parsed as a unified diff.",
"noContentDiff": "No content diff",
"noContentDiffDesc": "This version does not have a stored content diff.",
"source": "Source",
"skillMdPreview": "SKILL.md preview",
"sourceUnavailable": "Source unavailable",
"sourceUnavailableDesc": "This version points to a missing or unreadable SKILL.md path.",
"analyses": "Analyses",
"recentAnalyses": "Recent execution analyses",
"noExecutionNote": "No execution note",
"analysisCompleted": "completed: {{value}} · tool issues: {{toolIssues}} · suggestions: {{suggestions}}",
"noAnalysesYet": "No analyses yet",
"noAnalysesDesc": "Execution analyses will appear after recorded task runs are persisted into SQLite."
},
"filter": {
"origin": "Origin:",
"allOrigins": "All Origins",
"tags": "Tags:",
"allTags": "All Tags"
},
"graph": {
"noGraphData": "No lineage graph data.",
"tooltipScore": "score: {{value}}",
"tooltipGeneration": "generation: {{value}}",
"tooltipOrigin": "origin: {{value}}"
},
"diffViewer": {
"noFiles": "No files in diff",
"old": "Old",
"new": "New"
},
"format": {
"noInstruction": "No instruction captured"
}
}

View file

@ -0,0 +1,34 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './en.json';
import zh from './zh.json';
const STORAGE_KEY = 'openspace-lang';
function getSavedLanguage(): string {
try {
return localStorage.getItem(STORAGE_KEY) || 'en';
} catch {
return 'en';
}
}
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh },
},
lng: getSavedLanguage(),
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
i18n.on('languageChanged', (lng) => {
try {
localStorage.setItem(STORAGE_KEY, lng);
} catch {
// ignore
}
});
export default i18n;

253
frontend/src/i18n/zh.json Normal file
View file

@ -0,0 +1,253 @@
{
"nav": {
"dashboard": "仪表盘",
"skills": "技能",
"workflows": "工作流"
},
"common": {
"loading": "加载中…",
"yes": "是",
"no": "否",
"none": "无",
"unavailable": "不可用",
"unknown": "未知",
"close": "关闭",
"score": "评分",
"success": "成功率",
"active": "活跃",
"inactive": "未激活",
"noDescription": "暂无描述",
"steps_one": "{{count}} 步",
"steps_other": "{{count}} 步",
"agentActions_one": "{{count}} 个智能体操作",
"agentActions_other": "{{count}} 个智能体操作",
"tags": "+{{count}} 个标签"
},
"langSwitch": {
"label": "语言"
},
"errorBoundary": {
"title": "出现了错误",
"message": "发生了意外错误",
"goToDashboard": "返回仪表盘",
"details": "错误详情"
},
"dashboard": {
"title": "仪表盘",
"loadingDashboard": "正在加载仪表盘…",
"failedToLoad": "加载概览失败",
"dashboardUnavailable": "仪表盘不可用",
"totalSkills": "技能总数",
"activeHint": "活跃:{{count}}",
"avgSkillScore": "平均技能评分",
"avgScoreHint": "主要指标 = 有效率 × 100",
"workflowSessions": "工作流会话",
"localRepo": "本地仓库",
"workspace": "工作区",
"recordedUnder": "记录于{{location}}",
"workflowSuccess": "工作流成功率",
"avgSuccessHint": "平均会话成功率",
"health": "健康状态",
"runtimeSnapshot": "运行时快照",
"status": "状态",
"dbPath": "数据库路径",
"workflowCount": "工作流数量",
"builtFrontend": "前端构建",
"skillsSection": "技能",
"topScoredSkills": "评分最高的技能",
"noSkillsYet": "暂无技能",
"noSkillsDesc": "先运行 OpenSpace 任务或将技能同步到本地注册表中。",
"effective": "有效率 {{value}}",
"applied": "应用率 {{value}}",
"selections": "选择次数 {{count}}",
"workflowsSection": "工作流",
"recentSessions": "近期会话",
"noWorkflowSessions": "暂无工作流会话",
"noWorkflowDesc": "启用录制后执行任务,录制数据将在此显示。"
},
"skills": {
"title": "技能类别",
"searchPlaceholder": "按名称、ID、描述、标签或来源搜索",
"sortByScore": "按最佳评分排序",
"sortByUpdated": "按更新时间排序",
"sortByName": "按名称排序",
"skillClasses": "技能类别",
"versionsHint": "版本数:{{count}}",
"activeVersions": "活跃版本",
"withActivity": "有活动记录:{{count}}",
"avgBestScore": "平均最佳评分",
"bestNodeScoreHint": "每个类别的最佳节点评分",
"selections": "选择次数",
"completionsHint": "完成次数:{{count}}",
"loadingSkills": "正在加载技能…",
"failedToLoad": "加载技能失败",
"noSkillsMatch": "没有匹配的技能",
"noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的技能遥测数据写入 SQLite。",
"bestScore": "最佳评分",
"noClassDescription": "暂无类别描述",
"versions": "{{count}} 个版本",
"active": "{{count}} 个活跃",
"selectionsCount": "{{count}} 次选择"
},
"skillDetail": {
"loadingDetail": "正在加载技能详情…",
"failedToLoad": "加载技能类别失败",
"skillNotFound": "未找到技能",
"backToSkills": "← 返回技能列表",
"anchoredOn": "技能类别锚定于 {{id}}",
"skillClass": "技能类别",
"evolutionOverview": "演化概览",
"activeTip": "活跃端点",
"inactiveAnchor": "未激活锚点",
"bestVersionScore": "最佳版本评分",
"skillDirectory": "技能目录",
"latestVersionCreated": "最新版本创建时间",
"representativeVersion": "代表版本",
"representativeUpdate": "代表版本更新时间",
"versions": "版本数",
"maxGeneration": "最大世代 {{count}}",
"activeVersions": "活跃版本",
"originsCount": "来源数:{{count}}",
"averageScore": "平均评分",
"acrossAllVersions": "该谱系中所有版本的平均值",
"selections": "选择次数",
"representativeScore": "代表评分 {{score}}",
"evolutionGraph": "演化图",
"versionLineage": "版本谱系",
"loadingDrawer": "正在加载版本抽屉…",
"noLineageGraph": "暂无谱系图",
"noLineageGraphDesc": "该技能尚无可供可视化的谱系数据。"
},
"workflows": {
"title": "录制会话",
"searchPlaceholder": "按任务名称或指令搜索",
"workflowSessions": "工作流会话",
"scannedFrom": "从 logs/recordings 和 logs/trajectories 扫描",
"averageSuccess": "平均成功率",
"meanSuccessRate": "各会话的平均成功率",
"loadingWorkflows": "正在加载工作流…",
"failedToLoad": "加载工作流失败",
"noSessions": "暂无工作流会话",
"noSessionsDesc": "启用录制运行 `openspace` 后刷新此页面。",
"more": "+{{count}} 更多"
},
"workflowDetail": {
"loadingDetail": "正在加载工作流详情…",
"failedToLoad": "加载工作流失败",
"workflowNotFound": "未找到工作流",
"backToWorkflows": "← 返回工作流列表",
"workflowDetail": "工作流详情",
"skillsSelected_one": "已选择 {{count}} 个技能",
"skillsSelected_other": "已选择 {{count}} 个技能",
"mergedEvent_one": "{{count}} 个合并事件",
"mergedEvent_other": "{{count}} 个合并事件",
"runDescription": "{{status}}运行,包含 {{iterations}} 和 {{actions}}。",
"started": "开始时间",
"duration": "持续时间",
"stepsLabel": "步骤",
"latestEvent": "最新事件",
"successRate": "成功率",
"successRateHint_one": "{{iterations}}中 {{count}} 次成功迭代",
"successRateHint_other": "{{iterations}}中 {{count}} 次成功迭代",
"iterations": "迭代次数",
"totalRuntime": "总运行时间 {{duration}}",
"activeBackends": "活跃后端",
"mostActive": "最活跃 {{backend}} · {{count}} 个事件",
"noRecordedActivity": "无已记录的工具活动",
"timelineEvents": "时间线事件",
"agentToolHint": "{{agentCount}} 个智能体操作 · {{toolCount}} 个工具事件",
"noTimelineData": "暂无时间线数据",
"noTimelineDesc": "此会话尚未包含轨迹或智能体操作记录。",
"rawEventJson": "原始事件 JSON",
"selection": "技能选择",
"selectedSkills": "已选技能",
"noSelectedSkills": "未选择技能",
"noSelectedSkillsDesc": "此次运行未选择或记录任何技能。",
"session": "会话",
"overview": "概览",
"taskId": "任务 ID",
"runtime": "运行时",
"window": "时间窗口",
"ended": "结束于 {{date}}",
"selectionMethod": "选择方法",
"notRecorded": "未记录",
"enabledBackends": "已启用后端",
"backendActivity": "后端活动",
"iteration_one": "{{count}} 次迭代",
"iteration_other": "{{count}} 次迭代",
"step_one": "{{count}} 步",
"step_other": "{{count}} 步",
"agentAction_one": "{{count}} 个智能体操作",
"agentAction_other": "{{count}} 个智能体操作",
"successfulIteration_one": "{{count}} 次成功迭代",
"successfulIteration_other": "{{count}} 次成功迭代"
},
"drawer": {
"skillVersion": "技能版本",
"openAsMain": "作为主视图打开",
"versionSummary": "版本摘要",
"noDescriptionAvailable": "此版本暂无描述信息。",
"gen": "第 {{generation}} 代",
"versionScore": "版本评分",
"metrics": "指标",
"executionQuality": "执行质量",
"effectiveRate": "有效率",
"completionRate": "完成率",
"appliedRate": "应用率",
"fallbackRate": "回退率",
"selectionsLabel": "选择次数",
"appliedLabel": "应用次数",
"completionsLabel": "完成次数",
"fallbacksLabel": "回退次数",
"versionMetadata": "版本元数据",
"origin": "来源:",
"generation": "世代:",
"visibility": "可见性:",
"created": "创建时间:",
"firstSeen": "首次出现:",
"lastUpdated": "最后更新:",
"skillPath": "技能路径:",
"skillDir": "技能目录:",
"parentIds": "父级 ID",
"changeSummary": "变更摘要:",
"effectiveScore": "有效评分:",
"diff": "差异",
"contentDiff": "内容差异",
"diffTooLarge": "差异过大",
"diffTooLargeDesc": "此版本的内容差异过大,内联查看器已禁用。",
"diffUnavailable": "差异不可用",
"diffUnavailableDesc": "此版本有差异数据,但无法解析为统一差异格式。",
"noContentDiff": "无内容差异",
"noContentDiffDesc": "此版本没有存储的内容差异。",
"source": "源代码",
"skillMdPreview": "SKILL.md 预览",
"sourceUnavailable": "源代码不可用",
"sourceUnavailableDesc": "此版本指向的 SKILL.md 路径缺失或不可读。",
"analyses": "分析",
"recentAnalyses": "近期执行分析",
"noExecutionNote": "无执行备注",
"analysisCompleted": "已完成:{{value}} · 工具问题:{{toolIssues}} · 建议:{{suggestions}}",
"noAnalysesYet": "暂无分析",
"noAnalysesDesc": "执行分析将在已记录的任务运行持久化到 SQLite 后显示。"
},
"filter": {
"origin": "来源:",
"allOrigins": "所有来源",
"tags": "标签:",
"allTags": "所有标签"
},
"graph": {
"noGraphData": "暂无谱系图数据。",
"tooltipScore": "评分:{{value}}",
"tooltipGeneration": "世代:{{value}}",
"tooltipOrigin": "来源:{{value}}"
},
"diffViewer": {
"noFiles": "差异中无文件",
"old": "旧版",
"new": "新版"
},
"format": {
"noInstruction": "未捕获到指令"
}
}

View file

@ -32,7 +32,7 @@
--radius-chip: 999px;
--radius-card: 32px;
--radius-card-sm: 18px;
--font-serif: 'Neuton', Georgia, serif;
--font-serif: 'Neuton', 'Noto Serif CJK SC', 'Songti SC', 'SimSun', Georgia, serif;
--font-sans: 'Cabin', system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace;
--shadow-hard: 4px 4px 0px 0px rgba(74, 59, 42, 0.1);

View file

@ -1,4 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const linkClass = ({ isActive }: { isActive: boolean }) =>
isActive
@ -6,6 +7,12 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
: 'hover:text-primary';
export default function MainLayout() {
const { t, i18n } = useTranslation();
const toggleLang = () => {
i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh');
};
return (
<div className="h-screen min-w-[1180px] relative flex flex-col overflow-x-auto overflow-y-hidden bg-bg-page text-ink">
<nav className="relative z-10 flex justify-between items-center px-4 py-3 border-b border-[color:var(--color-border)] bg-bg-page">
@ -13,17 +20,26 @@ export default function MainLayout() {
<div className="font-bold text-3xl tracking-tighter font-serif">OpenSpace</div>
<div className="flex gap-4 text-sm">
<NavLink to="/dashboard" className={linkClass}>
Dashboard
{t('nav.dashboard')}
</NavLink>
<NavLink to="/skills" className={linkClass}>
Skills
{t('nav.skills')}
</NavLink>
<NavLink to="/workflows" className={linkClass}>
Workflows
{t('nav.workflows')}
</NavLink>
</div>
</div>
<div className="text-xs text-muted">API: `localhost:7788` · Vite: `localhost:3888`</div>
<div className="flex items-center gap-4">
<button
type="button"
onClick={toggleLang}
className="px-2.5 py-1 text-xs border border-[color:var(--color-border-dark)] rounded hover:bg-[color:var(--color-surface)] transition-colors cursor-pointer bg-transparent text-ink"
>
{i18n.language === 'zh' ? 'EN' : '中文'}
</button>
<div className="text-xs text-muted">API: `localhost:7788` · Vite: `localhost:3888`</div>
</div>
</nav>
<main className="app-scroll-region relative z-10 min-h-0 flex-1 overflow-auto">

View file

@ -1,5 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './i18n';
import './index.css';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';

View file

@ -1,11 +1,13 @@
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { overviewApi, type OverviewResponse } from '../api';
import MetricCard from '../components/MetricCard';
import EmptyState from '../components/EmptyState';
import { formatDate, formatInstruction, formatPercent, truncate } from '../utils/format';
export default function DashboardPage() {
const { t } = useTranslation();
const [data, setData] = useState<OverviewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -22,7 +24,7 @@ export default function DashboardPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load overview');
setError(err instanceof Error ? err.message : t('dashboard.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -34,37 +36,37 @@ export default function DashboardPage() {
return () => {
cancelled = true;
};
}, []);
}, [t]);
if (loading) {
return <div className="p-6 text-sm text-muted">Loading dashboard</div>;
return <div className="p-6 text-sm text-muted">{t('dashboard.loadingDashboard')}</div>;
}
if (error || !data) {
return <div className="p-6 text-sm text-danger">{error ?? 'Dashboard unavailable'}</div>;
return <div className="p-6 text-sm text-danger">{error ?? t('dashboard.dashboardUnavailable')}</div>;
}
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold font-serif">Dashboard</h1>
<h1 className="text-3xl font-bold font-serif">{t('dashboard.title')}</h1>
<section className="metrics-row">
<MetricCard label="Total Skills" value={data.skills.summary.total_skills_all} hint={`Active: ${data.skills.summary.total_skills}`} />
<MetricCard label="Average Skill Score" value={data.skills.average_score.toFixed(1)} hint="Primary metric = effective rate × 100" />
<MetricCard label="Workflow Sessions" value={data.workflows.total} hint={`Recorded under ${data.health.db_path.includes('.openspace') ? 'local repo' : 'workspace'}`} />
<MetricCard label="Workflow Success" value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint="Average session success rate" />
<MetricCard label={t('dashboard.totalSkills')} value={data.skills.summary.total_skills_all} hint={t('dashboard.activeHint', { count: data.skills.summary.total_skills })} />
<MetricCard label={t('dashboard.avgSkillScore')} value={data.skills.average_score.toFixed(1)} hint={t('dashboard.avgScoreHint')} />
<MetricCard label={t('dashboard.workflowSessions')} value={data.workflows.total} hint={t('dashboard.recordedUnder', { location: data.health.db_path.includes('.openspace') ? t('dashboard.localRepo') : t('dashboard.workspace') })} />
<MetricCard label={t('dashboard.workflowSuccess')} value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint={t('dashboard.avgSuccessHint')} />
</section>
<section>
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Health</div>
<h2 className="text-2xl font-bold font-serif mt-1">Runtime snapshot</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.health')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.runtimeSnapshot')}</h2>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between"><span className="text-muted">Status</span><span>{data.health.status}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">DB Path</span><span className="text-right break-all">{data.health.db_path}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">Workflow Count</span><span>{data.health.workflow_count}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">Built Frontend</span><span>{data.health.frontend_dist_exists ? 'yes' : 'no'}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.status')}</span><span>{data.health.status}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.dbPath')}</span><span className="text-right break-all">{data.health.db_path}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.workflowCount')}</span><span>{data.health.workflow_count}</span></div>
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.builtFrontend')}</span><span>{data.health.frontend_dist_exists ? t('common.yes') : t('common.no')}</span></div>
</div>
</div>
</section>
@ -72,11 +74,11 @@ export default function DashboardPage() {
<section className="grid grid-cols-2 gap-6">
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Skills</div>
<h2 className="text-2xl font-bold font-serif mt-1">Top scored skills</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.skillsSection')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.topScoredSkills')}</h2>
</div>
{data.skills.top.length === 0 ? (
<EmptyState title="No skills yet" description="Run OpenSpace tasks or sync skills into the local registry first." />
<EmptyState title={t('dashboard.noSkillsYet')} description={t('dashboard.noSkillsDesc')} />
) : (
<div className="space-y-3">
{data.skills.top.map((skill) => (
@ -84,17 +86,17 @@ export default function DashboardPage() {
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="font-bold truncate">{skill.name}</div>
<div className="text-sm text-muted">{truncate(skill.description || 'No description', 110)}</div>
<div className="text-sm text-muted">{truncate(skill.description || t('common.noDescription'), 110)}</div>
</div>
<div className="text-right shrink-0">
<div className="text-2xl font-bold font-serif">{skill.score.toFixed(1)}</div>
<div className="text-xs text-muted">score</div>
<div className="text-xs text-muted">{t('common.score')}</div>
</div>
</div>
<div className="mt-3 flex gap-3 text-xs text-muted">
<span>effective {formatPercent(skill.effective_rate)}</span>
<span>applied {formatPercent(skill.applied_rate)}</span>
<span>selections {skill.total_selections}</span>
<span>{t('dashboard.effective', { value: formatPercent(skill.effective_rate) })}</span>
<span>{t('dashboard.applied', { value: formatPercent(skill.applied_rate) })}</span>
<span>{t('dashboard.selections', { count: skill.total_selections })}</span>
</div>
</Link>
))}
@ -104,11 +106,11 @@ export default function DashboardPage() {
<div className="panel-surface p-5 space-y-4">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Workflows</div>
<h2 className="text-2xl font-bold font-serif mt-1">Recent sessions</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.workflowsSection')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.recentSessions')}</h2>
</div>
{data.workflows.recent.length === 0 ? (
<EmptyState title="No workflow sessions" description="Recordings will appear after a task is executed with recording enabled." />
<EmptyState title={t('dashboard.noWorkflowSessions')} description={t('dashboard.noWorkflowDesc')} />
) : (
<div className="space-y-3">
{data.workflows.recent.map((workflow) => (
@ -116,16 +118,16 @@ export default function DashboardPage() {
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="font-bold truncate">{workflow.task_name}</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 160)}</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 160, t('format.noInstruction'))}</div>
</div>
<div className="text-right shrink-0">
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
<div className="text-xs text-muted">success</div>
<div className="text-xs text-muted">{t('common.success')}</div>
</div>
</div>
<div className="mt-3 flex gap-3 text-xs text-muted">
<span>{workflow.total_steps} steps</span>
<span>{workflow.agent_action_count} agent actions</span>
<span>{t('common.steps', { count: workflow.total_steps })}</span>
<span>{t('common.agentActions', { count: workflow.agent_action_count })}</span>
<span>{formatDate(workflow.start_time)}</span>
</div>
</Link>

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { skillsApi, type SkillDetail, type SkillLineage } from '../api';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
@ -26,6 +27,7 @@ function resolveLineageGraph(skill: SkillDetail | null): SkillLineage | null {
const DRAWER_ANIMATION_DURATION_MS = 300;
export default function SkillDetailPage() {
const { t } = useTranslation();
const { skillId = '' } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [skillClass, setSkillClass] = useState<SkillDetail | null>(null);
@ -52,7 +54,7 @@ export default function SkillDetailPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load skill class');
setError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -68,7 +70,7 @@ export default function SkillDetailPage() {
return () => {
cancelled = true;
};
}, [skillId]);
}, [skillId, t]);
const lineageGraph = useMemo(() => resolveLineageGraph(skillClass), [skillClass]);
@ -97,7 +99,7 @@ export default function SkillDetailPage() {
} catch (err) {
if (!cancelled) {
setSelectedVersion(null);
setDrawerError(err instanceof Error ? err.message : 'Failed to load selected version');
setDrawerError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -110,7 +112,7 @@ export default function SkillDetailPage() {
return () => {
cancelled = true;
};
}, [selectedVersionId, skillClass]);
}, [selectedVersionId, skillClass, t]);
useEffect(() => {
if (selectedVersion) {
@ -215,20 +217,20 @@ export default function SkillDetailPage() {
};
if (loading) {
return <div className="p-6 text-sm text-muted">Loading skill detail</div>;
return <div className="p-6 text-sm text-muted">{t('skillDetail.loadingDetail')}</div>;
}
if (error || !skillClass) {
return <div className="p-6 text-sm text-danger">{error ?? 'Skill not found'}</div>;
return <div className="p-6 text-sm text-danger">{error ?? t('skillDetail.skillNotFound')}</div>;
}
return (
<div className="p-6 space-y-6 relative">
<div className="flex items-center gap-4">
<Link to="/skills" className="chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"> Back to Skills</Link>
<Link to="/skills" className="chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink">{t('skillDetail.backToSkills')}</Link>
<div className="min-w-0">
<h1 className="text-3xl font-bold font-serif truncate">{skillClass.name}</h1>
<div className="text-sm text-muted mt-1">Skill class anchored on {skillClass.skill_id}</div>
<div className="text-sm text-muted mt-1">{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}</div>
</div>
</div>
@ -236,13 +238,13 @@ export default function SkillDetailPage() {
<div className="flex items-start justify-between gap-6">
<div className="space-y-3 min-w-0 flex-1">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Skill Class</div>
<h2 className="text-2xl font-bold font-serif mt-1">Evolution overview</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.skillClass')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.evolutionOverview')}</h2>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="tag px-2 py-1">{skillClass.category}</span>
<span className="tag px-2 py-1">{skillClass.visibility}</span>
<span className="tag px-2 py-1">{skillClass.is_active ? 'active tip' : 'inactive anchor'}</span>
<span className="tag px-2 py-1">{skillClass.is_active ? t('skillDetail.activeTip') : t('skillDetail.inactiveAnchor')}</span>
{classSummary.origins.map((origin) => (
<span key={origin} className="tag px-2 py-1">{origin}</span>
))}
@ -250,48 +252,48 @@ export default function SkillDetailPage() {
<span key={tag} className="tag px-2 py-1">{tag}</span>
))}
{classSummary.tags.length > 8 ? (
<span className="tag px-2 py-1">+{classSummary.tags.length - 8} tags</span>
<span className="tag px-2 py-1">{t('common.tags', { count: classSummary.tags.length - 8 })}</span>
) : null}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-5xl font-bold font-serif leading-none">{classSummary.bestScore.toFixed(1)}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">best version score</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('skillDetail.bestVersionScore')}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm text-muted">
<div>
<div className="font-bold text-ink">Skill directory</div>
<div className="break-all">{skillClass.skill_dir || 'Unavailable'}</div>
<div className="font-bold text-ink">{t('skillDetail.skillDirectory')}</div>
<div className="break-all">{skillClass.skill_dir || t('common.unavailable')}</div>
</div>
<div>
<div className="font-bold text-ink">Latest version created</div>
<div className="font-bold text-ink">{t('skillDetail.latestVersionCreated')}</div>
<div>{formatDate(classSummary.latestCreatedAt)}</div>
</div>
<div>
<div className="font-bold text-ink">Representative version</div>
<div className="font-bold text-ink">{t('skillDetail.representativeVersion')}</div>
<div className="break-all">{skillClass.skill_id}</div>
</div>
<div>
<div className="font-bold text-ink">Representative update</div>
<div className="font-bold text-ink">{t('skillDetail.representativeUpdate')}</div>
<div>{formatDate(skillClass.last_updated)}</div>
</div>
</div>
</section>
<section className="metrics-row">
<MetricCard label="Versions" value={classSummary.versionCount} hint={`Max generation ${classSummary.maxGeneration}`} />
<MetricCard label="Active Versions" value={classSummary.activeCount} hint={`Origins: ${classSummary.origins.length}`} />
<MetricCard label="Average Score" value={classSummary.averageScore.toFixed(1)} hint="Across all versions in this lineage" />
<MetricCard label="Selections" value={classSummary.totalSelections} hint={`Representative score ${skillClass.score.toFixed(1)}`} />
<MetricCard label={t('skillDetail.versions')} value={classSummary.versionCount} hint={t('skillDetail.maxGeneration', { count: classSummary.maxGeneration })} />
<MetricCard label={t('skillDetail.activeVersions')} value={classSummary.activeCount} hint={t('skillDetail.originsCount', { count: classSummary.origins.length })} />
<MetricCard label={t('skillDetail.averageScore')} value={classSummary.averageScore.toFixed(1)} hint={t('skillDetail.acrossAllVersions')} />
<MetricCard label={t('skillDetail.selections')} value={classSummary.totalSelections} hint={t('skillDetail.representativeScore', { score: skillClass.score.toFixed(1) })} />
</section>
<section className="panel-surface overflow-hidden relative min-h-[620px]">
<div className="px-5 py-4 border-b border-[color:var(--color-border)] bg-surface flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">Evolution Graph</div>
<h2 className="text-2xl font-bold font-serif mt-1">Version lineage</h2>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.evolutionGraph')}</div>
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.versionLineage')}</h2>
</div>
<SkillVersionFilterBar
originFilter={originFilter}
@ -309,7 +311,7 @@ export default function SkillDetailPage() {
onBackgroundClick={closeDrawer}
/>
{drawerLoading ? (
<div className="absolute bottom-4 left-4 text-xs text-muted">Loading version drawer</div>
<div className="absolute bottom-4 left-4 text-xs text-muted">{t('skillDetail.loadingDrawer')}</div>
) : null}
{drawerError ? (
<div className="absolute bottom-4 left-4 text-xs text-danger">{drawerError}</div>
@ -317,7 +319,7 @@ export default function SkillDetailPage() {
</section>
{lineageGraph && lineageGraph.nodes.length === 0 ? (
<EmptyState title="No lineage graph" description="This skill does not yet have lineage data to visualize." />
<EmptyState title={t('skillDetail.noLineageGraph')} description={t('skillDetail.noLineageGraphDesc')} />
) : null}
<SkillVersionDrawer skill={drawerVersion} isOpen={Boolean(selectedVersion)} onClose={closeDrawer} />

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { skillsApi, type Skill, type SkillStats } from '../api';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
@ -7,6 +8,7 @@ import { formatDate, truncate } from '../utils/format';
import { buildSkillClasses } from '../utils/skillClasses';
export default function SkillsPage() {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [stats, setStats] = useState<SkillStats | null>(null);
const [loading, setLoading] = useState(true);
@ -30,7 +32,7 @@ export default function SkillsPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load skills');
setError(err instanceof Error ? err.message : t('skills.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -42,7 +44,7 @@ export default function SkillsPage() {
return () => {
cancelled = true;
};
}, [sort]);
}, [sort, t]);
const skillClasses = useMemo(() => buildSkillClasses(skills), [skills]);
@ -89,37 +91,37 @@ export default function SkillsPage() {
<div className="p-6 space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold font-serif">Skill classes</h1>
<h1 className="text-3xl font-bold font-serif">{t('skills.title')}</h1>
</div>
<div className="flex gap-3 items-center">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by name, id, description, tag, or origin"
placeholder={t('skills.searchPlaceholder')}
className="px-3 py-2 min-w-[320px]"
/>
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className="px-3 py-2">
<option value="score">Sort by best score</option>
<option value="updated">Sort by updated time</option>
<option value="name">Sort by name</option>
<option value="score">{t('skills.sortByScore')}</option>
<option value="updated">{t('skills.sortByUpdated')}</option>
<option value="name">{t('skills.sortByName')}</option>
</select>
</div>
</div>
{stats ? (
<section className="metrics-row">
<MetricCard label="Skill Classes" value={skillClasses.length} hint={`Versions: ${stats.total_skills_all}`} />
<MetricCard label="Active Versions" value={totalActiveVersions} hint={`With activity: ${stats.skills_with_activity}`} />
<MetricCard label="Average Best Score" value={averageBestScore.toFixed(1)} hint="Best node score per class" />
<MetricCard label="Selections" value={stats.total_selections} hint={`Completions: ${stats.total_completions}`} />
<MetricCard label={t('skills.skillClasses')} value={skillClasses.length} hint={t('skills.versionsHint', { count: stats.total_skills_all })} />
<MetricCard label={t('skills.activeVersions')} value={totalActiveVersions} hint={t('skills.withActivity', { count: stats.skills_with_activity })} />
<MetricCard label={t('skills.avgBestScore')} value={averageBestScore.toFixed(1)} hint={t('skills.bestNodeScoreHint')} />
<MetricCard label={t('skills.selections')} value={stats.total_selections} hint={t('skills.completionsHint', { count: stats.total_completions })} />
</section>
) : null}
{loading ? <div className="text-sm text-muted">Loading skills</div> : null}
{loading ? <div className="text-sm text-muted">{t('skills.loadingSkills')}</div> : null}
{error ? <div className="text-sm text-danger">{error}</div> : null}
{!loading && !error && filteredClasses.length === 0 ? (
<EmptyState title="No skills match" description="Try another keyword, or execute tasks so new skill telemetry lands in SQLite." />
<EmptyState title={t('skills.noSkillsMatch')} description={t('skills.noSkillsMatchDesc')} />
) : null}
{!loading && !error && filteredClasses.length > 0 ? (
@ -137,18 +139,18 @@ export default function SkillsPage() {
</div>
<div className="text-right shrink-0">
<div className="text-3xl font-bold font-serif leading-none">{skillClass.best_score.toFixed(1)}</div>
<div className="text-xs text-muted">best score</div>
<div className="text-xs text-muted">{t('skills.bestScore')}</div>
</div>
</div>
<div className="text-sm text-muted">
{truncate(skillClass.representative.description || 'No class description', 160)}
{truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
</div>
<div className="grid grid-cols-4 gap-3 text-xs text-muted">
<div>{skillClass.version_count} versions</div>
<div>{skillClass.active_count} active</div>
<div>{skillClass.total_selections} selections</div>
<div>{t('skills.versions', { count: skillClass.version_count })}</div>
<div>{t('skills.active', { count: skillClass.active_count })}</div>
<div>{t('skills.selectionsCount', { count: skillClass.total_selections })}</div>
<div>{formatDate(skillClass.latest_updated)}</div>
</div>
@ -160,7 +162,7 @@ export default function SkillsPage() {
<span key={`${skillClass.class_id}-${tag}`} className="tag px-2 py-1">{tag}</span>
))}
{skillClass.tags.length > 5 ? (
<span className="tag px-2 py-1">+{skillClass.tags.length - 5} tags</span>
<span className="tag px-2 py-1">{t('common.tags', { count: skillClass.tags.length - 5 })}</span>
) : null}
</div>
</Link>

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { workflowsApi, type WorkflowDetail, type WorkflowTimelineEvent } from '../api';
import { formatDate, formatInstruction } from '../utils/format';
@ -48,10 +49,6 @@ function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function pluralize(value: number, singular: string, plural = `${singular}s`): string {
return `${value} ${value === 1 ? singular : plural}`;
}
function getString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
@ -367,6 +364,7 @@ function describeTimelineEvent(event: WorkflowTimelineEvent): TimelinePresentati
}
export default function WorkflowDetailPage() {
const { t } = useTranslation();
const { workflowId = '' } = useParams();
const [workflow, setWorkflow] = useState<WorkflowDetail | null>(null);
const [loading, setLoading] = useState(true);
@ -385,7 +383,7 @@ export default function WorkflowDetailPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load workflow');
setError(err instanceof Error ? err.message : t('workflowDetail.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -399,7 +397,7 @@ export default function WorkflowDetailPage() {
return () => {
cancelled = true;
};
}, [workflowId]);
}, [workflowId, t]);
const timeline = useMemo(() => {
const events = workflow?.timeline ?? [];
@ -464,7 +462,7 @@ export default function WorkflowDetailPage() {
return (
<div className="workflow-detail-page p-6">
<div className="mx-auto max-w-[1480px]">
<div className="workflow-panel p-6 text-sm text-muted">Loading workflow detail</div>
<div className="workflow-panel p-6 text-sm text-muted">{t('workflowDetail.loadingDetail')}</div>
</div>
</div>
);
@ -474,7 +472,7 @@ export default function WorkflowDetailPage() {
return (
<div className="workflow-detail-page p-6">
<div className="mx-auto max-w-[1480px]">
<div className="workflow-panel p-6 text-sm text-danger">{error ?? 'Workflow not found'}</div>
<div className="workflow-panel p-6 text-sm text-danger">{error ?? t('workflowDetail.workflowNotFound')}</div>
</div>
</div>
);
@ -487,16 +485,16 @@ export default function WorkflowDetailPage() {
const skillSelection = isRecord(metadata.skill_selection) ? metadata.skill_selection : null;
const selectionMethod = skillSelection && typeof skillSelection.method === 'string' ? skillSelection.method : null;
const executionDurationLabel = formatDurationSeconds(workflow.execution_time);
const selectedSkillLabel = `${pluralize(workflow.selected_skills.length, 'skill')} selected`;
const iterationsLabel = pluralize(workflow.iterations, 'iteration');
const totalStepLabel = pluralize(workflow.total_steps, 'step');
const actionCountLabel = pluralize(workflow.agent_action_count, 'agent action');
const selectedSkillLabel = t('workflowDetail.skillsSelected', { count: workflow.selected_skills.length });
const iterationsLabel = t('workflowDetail.iteration', { count: workflow.iterations });
const totalStepLabel = t('workflowDetail.step', { count: workflow.total_steps });
const actionCountLabel = t('workflowDetail.agentAction', { count: workflow.agent_action_count });
const agentActionCount = timelineSummary.byType.agent_action ?? 0;
const toolExecutionCount = timelineSummary.byType.tool_execution ?? 0;
const latestEventLabel = timelineSummary.lastTimestamp ? formatTimeLabel(timelineSummary.lastTimestamp) : '—';
const timelineEventLabel = pluralize(timelineSummary.total, 'merged event');
const timelineEventLabel = t('workflowDetail.mergedEvent', { count: timelineSummary.total });
const statusLabel = humanizeToken(workflow.status || 'unknown');
const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : 'Not recorded';
const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : t('workflowDetail.notRecorded');
const successRateLabel = formatPercent(workflow.success_rate);
return (
@ -510,7 +508,7 @@ export default function WorkflowDetailPage() {
to="/workflows"
className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"
>
Back to Workflows
{t('workflowDetail.backToWorkflows')}
</Link>
<WorkflowChip className={getStatusChipClasses(workflow.status)}>{statusLabel}</WorkflowChip>
<WorkflowChip>{selectedSkillLabel}</WorkflowChip>
@ -518,36 +516,36 @@ export default function WorkflowDetailPage() {
</div>
<div className="space-y-3">
<div className="workflow-kicker">Workflow detail</div>
<div className="workflow-kicker">{t('workflowDetail.workflowDetail')}</div>
<h1 className="max-w-5xl text-4xl font-semibold leading-[1.05] tracking-[-0.05em] text-ink lg:text-5xl xl:text-[3.6rem]">
{workflow.task_name}
</h1>
<p className="workflow-copy max-w-4xl text-lg leading-8 text-muted line-clamp-4">
{formatInstruction(workflow.instruction, 480)}
{formatInstruction(workflow.instruction, 480, t('format.noInstruction'))}
</p>
</div>
</div>
<div className="workflow-soft-card w-full max-w-sm shrink-0 p-5 space-y-5">
<p className="workflow-copy text-base leading-7 text-muted">
{`${statusLabel} run with ${iterationsLabel} and ${actionCountLabel}.`}
{t('workflowDetail.runDescription', { status: statusLabel, iterations: iterationsLabel, actions: actionCountLabel })}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<div className="workflow-kicker">Started</div>
<div className="workflow-kicker">{t('workflowDetail.started')}</div>
<div className="text-base font-medium text-ink">{formatDate(workflow.start_time)}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">Duration</div>
<div className="workflow-kicker">{t('workflowDetail.duration')}</div>
<div className="text-base font-medium text-ink">{executionDurationLabel}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">Steps</div>
<div className="workflow-kicker">{t('workflowDetail.stepsLabel')}</div>
<div className="text-base font-medium text-ink">{totalStepLabel}</div>
</div>
<div className="space-y-1">
<div className="workflow-kicker">Latest event</div>
<div className="workflow-kicker">{t('workflowDetail.latestEvent')}</div>
<div className="text-base font-medium text-ink">{latestEventLabel}</div>
</div>
</div>
@ -556,24 +554,24 @@ export default function WorkflowDetailPage() {
<section className="workflow-metrics-row">
<SummaryMetric
label="Success rate"
label={t('workflowDetail.successRate')}
value={successRateLabel}
hint={`${pluralize(workflow.success_count, 'successful iteration')} out of ${iterationsLabel}`}
hint={t('workflowDetail.successRateHint', { count: workflow.success_count, iterations: iterationsLabel })}
/>
<SummaryMetric
label="Iterations"
label={t('workflowDetail.iterations')}
value={workflow.iterations}
hint={`${executionDurationLabel} total runtime`}
hint={t('workflowDetail.totalRuntime', { duration: executionDurationLabel })}
/>
<SummaryMetric
label="Active backends"
label={t('workflowDetail.activeBackends')}
value={activityEntries.length}
hint={topBackendEntry ? `Most active ${humanizeToken(topBackendEntry[0])} · ${topBackendEntry[1]} events` : 'No recorded tool activity'}
hint={topBackendEntry ? t('workflowDetail.mostActive', { backend: humanizeToken(topBackendEntry[0]), count: topBackendEntry[1] }) : t('workflowDetail.noRecordedActivity')}
/>
<SummaryMetric
label="Timeline events"
label={t('workflowDetail.timelineEvents')}
value={timelineSummary.total}
hint={`${agentActionCount} agent actions · ${toolExecutionCount} tool events`}
hint={t('workflowDetail.agentToolHint', { agentCount: agentActionCount, toolCount: toolExecutionCount })}
/>
</section>
</section>
@ -582,8 +580,8 @@ export default function WorkflowDetailPage() {
<div className="workflow-panel p-5 space-y-4">
{timeline.length === 0 ? (
<QuietEmptyState
title="No timeline data"
description="This session does not yet contain trajectory or agent action records."
title={t('workflowDetail.noTimelineData')}
description={t('workflowDetail.noTimelineDesc')}
/>
) : (
<div role="list" aria-label="Workflow timeline events">
@ -677,7 +675,7 @@ export default function WorkflowDetailPage() {
) : null}
<div className="workflow-soft-card p-3.5">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">Raw event JSON</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">{t('workflowDetail.rawEventJson')}</div>
<pre className="workflow-json mt-3 whitespace-pre-wrap break-all text-xs leading-6 text-muted">
{stringify(event.details)}
</pre>
@ -697,8 +695,8 @@ export default function WorkflowDetailPage() {
<section className="workflow-panel p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="workflow-kicker">Selection</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">Selected skills</h2>
<div className="workflow-kicker">{t('workflowDetail.selection')}</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.selectedSkills')}</h2>
</div>
{workflow.selected_skills.length > 0 ? <WorkflowChip>{workflow.selected_skills.length}</WorkflowChip> : null}
</div>
@ -718,42 +716,42 @@ export default function WorkflowDetailPage() {
</div>
) : (
<div className="space-y-2">
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">No selected skills</div>
<p className="workflow-copy text-sm leading-6 text-muted">No skills were selected or recorded for this run.</p>
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">{t('workflowDetail.noSelectedSkills')}</div>
<p className="workflow-copy text-sm leading-6 text-muted">{t('workflowDetail.noSelectedSkillsDesc')}</p>
</div>
)}
</section>
<section className="workflow-panel p-5 space-y-5">
<div>
<div className="workflow-kicker">Session</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">Overview</h2>
<div className="workflow-kicker">{t('workflowDetail.session')}</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.overview')}</h2>
</div>
<div className="space-y-5">
<SidebarRow label="Task ID">
<SidebarRow label={t('workflowDetail.taskId')}>
<div className="break-all">{workflow.task_id}</div>
</SidebarRow>
<SidebarRow label="Runtime">
<SidebarRow label={t('workflowDetail.runtime')}>
<div>{executionDurationLabel}</div>
<div className="text-xs leading-6 text-muted">
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
</div>
</SidebarRow>
<SidebarRow label="Window">
<SidebarRow label={t('workflowDetail.window')}>
<div>{formatDate(workflow.start_time)}</div>
<div className="text-xs leading-6 text-muted">Ended {formatDate(workflow.end_time)}</div>
<div className="text-xs leading-6 text-muted">{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}</div>
</SidebarRow>
<SidebarRow label="Selection method">
<SidebarRow label={t('workflowDetail.selectionMethod')}>
<div>{selectionMethodLabel}</div>
<div className="text-xs leading-6 text-muted">{selectedSkillLabel}</div>
</SidebarRow>
{enabledBackends.length > 0 ? (
<SidebarRow label="Enabled backends">
<SidebarRow label={t('workflowDetail.enabledBackends')}>
<div className="flex flex-wrap gap-2 text-xs">
{enabledBackends.map((backend) => (
<WorkflowChip key={backend}>{humanizeToken(backend)}</WorkflowChip>
@ -763,7 +761,7 @@ export default function WorkflowDetailPage() {
) : null}
{activityEntries.length > 0 ? (
<SidebarRow label="Backend activity">
<SidebarRow label={t('workflowDetail.backendActivity')}>
<div className="flex flex-wrap gap-2 text-xs">
{activityEntries.map(([backend, count]) => (
<WorkflowChip key={backend}>{`${humanizeToken(backend)} ${count}`}</WorkflowChip>

View file

@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { workflowsApi, type WorkflowSummary } from '../api';
import EmptyState from '../components/EmptyState';
import { formatDate, formatInstruction } from '../utils/format';
export default function WorkflowsPage() {
const { t } = useTranslation();
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -22,7 +24,7 @@ export default function WorkflowsPage() {
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load workflows');
setError(err instanceof Error ? err.message : t('workflows.failedToLoad'));
}
} finally {
if (!cancelled) {
@ -34,7 +36,7 @@ export default function WorkflowsPage() {
return () => {
cancelled = true;
};
}, []);
}, [t]);
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
@ -56,34 +58,34 @@ export default function WorkflowsPage() {
<div className="p-6 space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold font-serif">Recorded sessions</h1>
<h1 className="text-3xl font-bold font-serif">{t('workflows.title')}</h1>
</div>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by task name or instruction"
placeholder={t('workflows.searchPlaceholder')}
className="px-3 py-2 min-w-[320px]"
/>
</div>
<section className="grid grid-cols-2 gap-4">
<div className="p-4 space-y-2">
<div className="text-xs uppercase tracking-[0.16em] text-muted">Workflow Sessions</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.workflowSessions')}</div>
<div className="text-3xl font-bold font-serif leading-none">{workflows.length}</div>
<div className="text-xs text-muted">Scanned from logs/recordings and logs/trajectories</div>
<div className="text-xs text-muted">{t('workflows.scannedFrom')}</div>
</div>
<div className="p-4 space-y-2">
<div className="text-xs uppercase tracking-[0.16em] text-muted">Average Success</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.averageSuccess')}</div>
<div className="text-3xl font-bold font-serif leading-none">{averageSuccess}%</div>
<div className="text-xs text-muted">Mean success rate across sessions</div>
<div className="text-xs text-muted">{t('workflows.meanSuccessRate')}</div>
</div>
</section>
{loading ? <div className="text-sm text-muted">Loading workflows</div> : null}
{loading ? <div className="text-sm text-muted">{t('workflows.loadingWorkflows')}</div> : null}
{error ? <div className="text-sm text-danger">{error}</div> : null}
{!loading && !error && filtered.length === 0 ? (
<EmptyState title="No workflow sessions" description="Run `openspace` with recording enabled, then refresh this page." />
<EmptyState title={t('workflows.noSessions')} description={t('workflows.noSessionsDesc')} />
) : null}
{!loading && !error && filtered.length > 0 ? (
@ -96,13 +98,13 @@ export default function WorkflowsPage() {
</div>
<div className="text-right shrink-0">
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
<div className="text-xs text-muted">success</div>
<div className="text-xs text-muted">{t('common.success')}</div>
</div>
</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 220)}</div>
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 220, t('format.noInstruction'))}</div>
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
<div>{workflow.total_steps} steps</div>
<div>{workflow.agent_action_count} agent actions</div>
<div>{t('common.steps', { count: workflow.total_steps })}</div>
<div>{t('common.agentActions', { count: workflow.agent_action_count })}</div>
<div>{formatDate(workflow.start_time)}</div>
</div>
{workflow.selected_skills.length > 0 ? (
@ -114,7 +116,7 @@ export default function WorkflowsPage() {
))}
{workflow.selected_skills.length > 3 ? (
<span className="tag px-2 py-1 text-muted">
+{workflow.selected_skills.length - 3} more
{t('workflows.more', { count: workflow.selected_skills.length - 3 })}
</span>
) : null}
</div>

View file

@ -56,8 +56,9 @@ function shortenPaths(text: string, keep = 3): string {
export function formatInstruction(
raw: string | null | undefined,
maxLen?: number,
fallback = 'No instruction captured',
): string {
if (!raw) return 'No instruction captured';
if (!raw) return fallback;
let text = shortenPaths(raw);