@geminilight/mindos 0.5.60 → 0.5.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { useLocale } from '@/lib/LocaleContext';
6
+ import { useMcpData } from '@/hooks/useMcpData';
7
+ import { copyToClipboard } from '@/lib/clipboard';
8
+ import { generateSnippet } from '@/lib/mcp-snippets';
9
+ import {
10
+ bucketAgents,
11
+ buildRiskQueue,
12
+ type AgentsDashboardTab,
13
+ } from './agents-content-model';
14
+ import AgentsOverviewSection from './AgentsOverviewSection';
15
+ import AgentsMcpSection from './AgentsMcpSection';
16
+ import AgentsSkillsSection from './AgentsSkillsSection';
17
+
18
+ export default function AgentsContentPage({ tab }: { tab: AgentsDashboardTab }) {
19
+ const { t } = useLocale();
20
+ const a = t.agentsContent;
21
+ const mcp = useMcpData();
22
+ const [copyState, setCopyState] = useState<string | null>(null);
23
+
24
+ const buckets = useMemo(() => bucketAgents(mcp.agents), [mcp.agents]);
25
+ const riskQueue = useMemo(
26
+ () =>
27
+ buildRiskQueue({
28
+ mcpRunning: !!mcp.status?.running,
29
+ detectedCount: buckets.detected.length,
30
+ notFoundCount: buckets.notFound.length,
31
+ allSkillsDisabled: mcp.skills.length > 0 && mcp.skills.every((s) => !s.enabled),
32
+ }),
33
+ [mcp.skills, mcp.status?.running, buckets.detected.length, buckets.notFound.length],
34
+ );
35
+
36
+ const navClass = (target: AgentsDashboardTab) =>
37
+ `px-3 py-1.5 text-xs rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
38
+ tab === target
39
+ ? 'border-border bg-[var(--amber-dim)] text-[var(--amber)]'
40
+ : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted'
41
+ }`;
42
+
43
+ const copySnippet = async (agentKey: string) => {
44
+ const agent = mcp.agents.find((item) => item.key === agentKey);
45
+ if (!agent) return;
46
+ const snippet = generateSnippet(agent, mcp.status, agent.preferredTransport);
47
+ const ok = await copyToClipboard(snippet.snippet);
48
+ if (!ok) return;
49
+ setCopyState(agentKey);
50
+ setTimeout(() => setCopyState(null), 1500);
51
+ };
52
+
53
+ return (
54
+ <div className="content-width px-4 md:px-6 py-8 md:py-10">
55
+ <header className="mb-6">
56
+ <h1 className="text-2xl font-semibold tracking-tight font-display text-foreground">{a.title}</h1>
57
+ <p className="mt-1 text-sm text-muted-foreground">{a.subtitle}</p>
58
+ </header>
59
+
60
+ <div className="mb-6 flex items-center gap-2 border-b border-border pb-3" role="tablist" aria-label={a.title}>
61
+ <Link href="/agents" role="tab" id="agents-tab-overview" aria-controls="agents-panel-overview" aria-selected={tab === 'overview'} className={navClass('overview')}>{a.navOverview}</Link>
62
+ <Link href="/agents?tab=mcp" role="tab" id="agents-tab-mcp" aria-controls="agents-panel-mcp" aria-selected={tab === 'mcp'} className={navClass('mcp')}>{a.navMcp}</Link>
63
+ <Link href="/agents?tab=skills" role="tab" id="agents-tab-skills" aria-controls="agents-panel-skills" aria-selected={tab === 'skills'} className={navClass('skills')}>{a.navSkills}</Link>
64
+ </div>
65
+
66
+ {tab === 'overview' && (
67
+ <AgentsOverviewSection
68
+ copy={a.overview}
69
+ buckets={buckets}
70
+ riskQueue={riskQueue}
71
+ topSkillsLabel={a.overview.topSkills}
72
+ failedAgentsLabel={a.overview.failedAgents}
73
+ topSkillsValue={mcp.skills.filter((s) => s.enabled).slice(0, 3).map((s) => s.name).join(', ') || a.overview.na}
74
+ failedAgentsValue={buckets.notFound.map((x) => x.name).slice(0, 3).join(', ') || a.overview.na}
75
+ />
76
+ )}
77
+
78
+ {tab === 'mcp' && (
79
+ <AgentsMcpSection copy={{ ...a.mcp, status: a.status }} mcp={mcp} buckets={buckets} copyState={copyState} onCopySnippet={copySnippet} />
80
+ )}
81
+
82
+ {tab === 'skills' && (
83
+ <AgentsSkillsSection copy={a.skills} mcp={mcp} buckets={buckets} />
84
+ )}
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { RefreshCw, Server } from 'lucide-react';
5
+ import type { McpContextValue } from '@/hooks/useMcpData';
6
+ import type { AgentBuckets } from './agents-content-model';
7
+
8
+ export default function AgentsMcpSection({
9
+ copy,
10
+ mcp,
11
+ buckets,
12
+ copyState,
13
+ onCopySnippet,
14
+ }: {
15
+ copy: {
16
+ title: string;
17
+ refresh: string;
18
+ connectionGraph: string;
19
+ table: { agent: string; status: string; transport: string; actions: string };
20
+ actions: { copySnippet: string; copied: string; testConnection: string; reconnect: string };
21
+ status: { connected: string; detected: string; notFound: string };
22
+ };
23
+ mcp: McpContextValue;
24
+ buckets: AgentBuckets;
25
+ copyState: string | null;
26
+ onCopySnippet: (agentKey: string) => Promise<void>;
27
+ }) {
28
+ return (
29
+ <section role="tabpanel" id="agents-panel-mcp" aria-labelledby="agents-tab-mcp" className="rounded-lg border border-border bg-card p-4 space-y-4">
30
+ <div className="flex items-center justify-between">
31
+ <h2 className="text-sm font-medium text-foreground flex items-center gap-2">
32
+ <Server size={15} className="text-muted-foreground" />
33
+ {copy.title}
34
+ </h2>
35
+ <button
36
+ type="button"
37
+ onClick={() => void mcp.refresh()}
38
+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
39
+ >
40
+ <RefreshCw size={13} />
41
+ {copy.refresh}
42
+ </button>
43
+ </div>
44
+
45
+ <div className="rounded-md border border-border bg-background p-3">
46
+ <p className="text-xs font-medium text-muted-foreground mb-2">{copy.connectionGraph}</p>
47
+ <div className="flex flex-wrap items-center gap-2 text-xs">
48
+ <NodePill label={copy.status.connected} count={buckets.connected.length} tone="ok" />
49
+ <span className="text-muted-foreground">→</span>
50
+ <NodePill label={copy.status.detected} count={buckets.detected.length} tone="warn" />
51
+ <span className="text-muted-foreground">→</span>
52
+ <NodePill label={copy.status.notFound} count={buckets.notFound.length} tone="neutral" />
53
+ <span className="mx-2 text-muted-foreground">|</span>
54
+ <NodePill label={copy.title} count={mcp.status?.running ? 1 : 0} tone={mcp.status?.running ? 'ok' : 'neutral'} />
55
+ </div>
56
+ </div>
57
+
58
+ <div className="overflow-x-auto">
59
+ <table className="w-full text-sm">
60
+ <thead>
61
+ <tr className="text-left border-b border-border">
62
+ <th className="py-2 font-medium text-muted-foreground">{copy.table.agent}</th>
63
+ <th className="py-2 font-medium text-muted-foreground">{copy.table.status}</th>
64
+ <th className="py-2 font-medium text-muted-foreground">{copy.table.transport}</th>
65
+ <th className="py-2 font-medium text-muted-foreground">{copy.table.actions}</th>
66
+ </tr>
67
+ </thead>
68
+ <tbody>
69
+ {mcp.agents.map((agent) => (
70
+ <tr key={agent.key} className="border-b border-border/60">
71
+ <td className="py-2 text-foreground">
72
+ <Link href={`/agents/${encodeURIComponent(agent.key)}`} className="hover:underline">{agent.name}</Link>
73
+ </td>
74
+ <td className="py-2 text-muted-foreground">{agent.present ? (agent.installed ? copy.status.connected : copy.status.detected) : copy.status.notFound}</td>
75
+ <td className="py-2 text-muted-foreground">{agent.transport ?? agent.preferredTransport}</td>
76
+ <td className="py-2">
77
+ <div className="flex flex-wrap items-center gap-2">
78
+ <button
79
+ type="button"
80
+ onClick={() => void onCopySnippet(agent.key)}
81
+ className="text-xs px-2 py-1 rounded border border-border hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
82
+ >
83
+ {copyState === agent.key ? copy.actions.copied : copy.actions.copySnippet}
84
+ </button>
85
+ <button type="button" className="text-xs px-2 py-1 rounded border border-border hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
86
+ {copy.actions.testConnection}
87
+ </button>
88
+ <button type="button" className="text-xs px-2 py-1 rounded border border-border hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
89
+ {copy.actions.reconnect}
90
+ </button>
91
+ </div>
92
+ </td>
93
+ </tr>
94
+ ))}
95
+ </tbody>
96
+ </table>
97
+ </div>
98
+ </section>
99
+ );
100
+ }
101
+
102
+ function NodePill({
103
+ label,
104
+ count,
105
+ tone,
106
+ }: {
107
+ label: string;
108
+ count: number;
109
+ tone: 'ok' | 'warn' | 'neutral';
110
+ }) {
111
+ const cls =
112
+ tone === 'ok'
113
+ ? 'bg-success/10 text-success'
114
+ : tone === 'warn'
115
+ ? 'bg-[var(--amber-dim)] text-[var(--amber)]'
116
+ : 'bg-muted text-muted-foreground';
117
+ return (
118
+ <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md ${cls}`}>
119
+ <span>{label}</span>
120
+ <span className="tabular-nums">{count}</span>
121
+ </span>
122
+ );
123
+ }
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import { AlertTriangle, CheckCircle2, Wrench } from 'lucide-react';
4
+ import type { AgentBuckets, RiskItem } from './agents-content-model';
5
+
6
+ export default function AgentsOverviewSection({
7
+ copy,
8
+ buckets,
9
+ riskQueue,
10
+ topSkillsLabel,
11
+ failedAgentsLabel,
12
+ topSkillsValue,
13
+ failedAgentsValue,
14
+ }: {
15
+ copy: {
16
+ connected: string;
17
+ detected: string;
18
+ notFound: string;
19
+ riskQueue: string;
20
+ noRisk: string;
21
+ usagePulse: string;
22
+ successRate7d: string;
23
+ topSkills: string;
24
+ failedAgents: string;
25
+ na: string;
26
+ };
27
+ buckets: AgentBuckets;
28
+ riskQueue: RiskItem[];
29
+ topSkillsLabel: string;
30
+ failedAgentsLabel: string;
31
+ topSkillsValue: string;
32
+ failedAgentsValue: string;
33
+ }) {
34
+ return (
35
+ <div className="space-y-4" role="tabpanel" id="agents-panel-overview" aria-labelledby="agents-tab-overview">
36
+ <section className="grid grid-cols-1 md:grid-cols-3 gap-3">
37
+ <StatCard title={copy.connected} value={String(buckets.connected.length)} tone="ok" />
38
+ <StatCard title={copy.detected} value={String(buckets.detected.length)} tone="warn" />
39
+ <StatCard title={copy.notFound} value={String(buckets.notFound.length)} tone="warn" />
40
+ </section>
41
+
42
+ <section className="rounded-lg border border-border bg-card p-4">
43
+ <h2 className="text-sm font-medium text-foreground mb-3">{copy.riskQueue}</h2>
44
+ {riskQueue.length === 0 ? (
45
+ <p className="text-sm text-muted-foreground">{copy.noRisk}</p>
46
+ ) : (
47
+ <ul className="space-y-2">
48
+ {riskQueue.map((risk) => (
49
+ <li key={risk.id} className="flex items-start gap-2 text-sm">
50
+ <AlertTriangle size={14} className={risk.severity === 'error' ? 'text-destructive mt-0.5' : 'text-[var(--amber)] mt-0.5'} />
51
+ <span className="text-foreground">{risk.title}</span>
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ )}
56
+ </section>
57
+
58
+ <section className="rounded-lg border border-border bg-card p-4">
59
+ <h2 className="text-sm font-medium text-foreground mb-3">{copy.usagePulse}</h2>
60
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
61
+ <InfoLine label={copy.successRate7d} value={copy.na} />
62
+ <InfoLine label={topSkillsLabel || copy.topSkills} value={topSkillsValue} />
63
+ <InfoLine label={failedAgentsLabel || copy.failedAgents} value={failedAgentsValue} />
64
+ </div>
65
+ </section>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function StatCard({ title, value, tone }: { title: string; value: string; tone: 'ok' | 'warn' }) {
71
+ return (
72
+ <div className="rounded-lg border border-border bg-card p-4">
73
+ <div className="flex items-center justify-between mb-2">
74
+ <p className="text-xs text-muted-foreground">{title}</p>
75
+ {tone === 'ok' ? <CheckCircle2 size={14} className="text-success" /> : <Wrench size={14} className="text-[var(--amber)]" />}
76
+ </div>
77
+ <p className="text-xl font-semibold text-foreground">{value}</p>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ function InfoLine({ label, value }: { label: string; value: string }) {
83
+ return (
84
+ <div className="rounded-md border border-border px-3 py-2">
85
+ <p className="text-2xs text-muted-foreground mb-1">{label}</p>
86
+ <p className="text-sm text-foreground truncate">{value}</p>
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import { ChevronDown, ChevronRight, Search } from 'lucide-react';
5
+ import { Toggle } from '@/components/settings/Primitives';
6
+ import type { McpContextValue } from '@/hooks/useMcpData';
7
+ import type { AgentBuckets, SkillSourceFilter } from './agents-content-model';
8
+ import { filterSkills, groupSkillsByCapability } from './agents-content-model';
9
+
10
+ export default function AgentsSkillsSection({
11
+ copy,
12
+ mcp,
13
+ buckets,
14
+ }: {
15
+ copy: {
16
+ title: string;
17
+ capabilityGroups: string;
18
+ searchPlaceholder: string;
19
+ sourceAll: string;
20
+ sourceBuiltin: string;
21
+ sourceUser: string;
22
+ emptyGroup: string;
23
+ matrixToggle: string;
24
+ groupLabels: Record<'research' | 'coding' | 'docs' | 'ops' | 'memory', string>;
25
+ };
26
+ mcp: McpContextValue;
27
+ buckets: AgentBuckets;
28
+ }) {
29
+ const [query, setQuery] = useState('');
30
+ const [source, setSource] = useState<SkillSourceFilter>('all');
31
+ const [matrixOpen, setMatrixOpen] = useState(false);
32
+
33
+ const filtered = useMemo(() => filterSkills(mcp.skills, query, source), [mcp.skills, query, source]);
34
+ const grouped = useMemo(() => groupSkillsByCapability(filtered), [filtered]);
35
+ const knownAgents = useMemo(() => [...buckets.connected, ...buckets.detected, ...buckets.notFound], [buckets]);
36
+
37
+ return (
38
+ <section role="tabpanel" id="agents-panel-skills" aria-labelledby="agents-tab-skills" className="rounded-lg border border-border bg-card p-4 space-y-4">
39
+ <div>
40
+ <h2 className="text-sm font-medium text-foreground mb-1">{copy.title}</h2>
41
+ <p className="text-xs text-muted-foreground">{copy.capabilityGroups}</p>
42
+ </div>
43
+
44
+ <div className="flex flex-col md:flex-row gap-2">
45
+ <label className="relative flex-1">
46
+ <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
47
+ <input
48
+ value={query}
49
+ onChange={(e) => setQuery(e.target.value)}
50
+ placeholder={copy.searchPlaceholder}
51
+ className="w-full h-9 rounded-md border border-border bg-background pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
52
+ />
53
+ </label>
54
+ <div className="flex items-center gap-1 rounded-md border border-border p-1 bg-background">
55
+ <SourceFilterButton active={source === 'all'} label={copy.sourceAll} onClick={() => setSource('all')} />
56
+ <SourceFilterButton active={source === 'builtin'} label={copy.sourceBuiltin} onClick={() => setSource('builtin')} />
57
+ <SourceFilterButton active={source === 'user'} label={copy.sourceUser} onClick={() => setSource('user')} />
58
+ </div>
59
+ </div>
60
+
61
+ <div className="space-y-4">
62
+ {Object.entries(grouped).map(([groupKey, skills]) => (
63
+ <div key={groupKey} className="rounded-md border border-border p-3">
64
+ <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
65
+ {copy.groupLabels[groupKey as keyof typeof copy.groupLabels]} ({skills.length})
66
+ </div>
67
+ {skills.length === 0 ? (
68
+ <p className="text-xs text-muted-foreground">{copy.emptyGroup}</p>
69
+ ) : (
70
+ <div className="space-y-1.5">
71
+ {skills.map((skill) => (
72
+ <div key={skill.name} className="flex items-center justify-between gap-2">
73
+ <div className="min-w-0">
74
+ <p className="text-sm text-foreground truncate">{skill.name}</p>
75
+ <p className="text-2xs text-muted-foreground">{skill.source === 'builtin' ? copy.sourceBuiltin : copy.sourceUser}</p>
76
+ </div>
77
+ <Toggle size="sm" checked={skill.enabled} onChange={(v) => void mcp.toggleSkill(skill.name, v)} />
78
+ </div>
79
+ ))}
80
+ </div>
81
+ )}
82
+ </div>
83
+ ))}
84
+ </div>
85
+
86
+ <div className="rounded-md border border-border bg-background p-3">
87
+ <button
88
+ type="button"
89
+ onClick={() => setMatrixOpen((v) => !v)}
90
+ className="w-full flex items-center justify-between text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
91
+ >
92
+ <span>{copy.matrixToggle}</span>
93
+ {matrixOpen ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
94
+ </button>
95
+ {matrixOpen && (
96
+ <div className="mt-3 overflow-x-auto">
97
+ <table className="w-full text-xs">
98
+ <thead>
99
+ <tr className="border-b border-border">
100
+ <th className="text-left py-2 text-muted-foreground font-medium">Skill</th>
101
+ {knownAgents.map((agent) => (
102
+ <th key={agent.key} className="text-left py-2 text-muted-foreground font-medium pr-3">{agent.name}</th>
103
+ ))}
104
+ </tr>
105
+ </thead>
106
+ <tbody>
107
+ {filtered.map((skill) => (
108
+ <tr key={skill.name} className="border-b border-border/40">
109
+ <td className="py-2 text-foreground pr-3">{skill.name}</td>
110
+ {knownAgents.map((agent) => (
111
+ <td key={`${skill.name}:${agent.key}`} className="py-2 pr-3 text-muted-foreground">
112
+ {agent.present ? (skill.enabled ? 'Enabled' : 'Disabled') : 'Unsupported'}
113
+ </td>
114
+ ))}
115
+ </tr>
116
+ ))}
117
+ </tbody>
118
+ </table>
119
+ </div>
120
+ )}
121
+ </div>
122
+ </section>
123
+ );
124
+ }
125
+
126
+ function SourceFilterButton({
127
+ active,
128
+ label,
129
+ onClick,
130
+ }: {
131
+ active: boolean;
132
+ label: string;
133
+ onClick: () => void;
134
+ }) {
135
+ return (
136
+ <button
137
+ type="button"
138
+ onClick={onClick}
139
+ className={`px-2.5 h-7 rounded text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
140
+ active ? 'bg-[var(--amber-dim)] text-[var(--amber)]' : 'text-muted-foreground hover:text-foreground hover:bg-muted'
141
+ }`}
142
+ >
143
+ {label}
144
+ </button>
145
+ );
146
+ }
@@ -0,0 +1,80 @@
1
+ import type { AgentInfo, SkillInfo } from '@/components/settings/types';
2
+
3
+ export type AgentsDashboardTab = 'overview' | 'mcp' | 'skills';
4
+ export type AgentResolvedStatus = 'connected' | 'detected' | 'notFound';
5
+ export type SkillCapability = 'research' | 'coding' | 'docs' | 'ops' | 'memory';
6
+ export type SkillSourceFilter = 'all' | 'builtin' | 'user';
7
+
8
+ export interface RiskItem {
9
+ id: string;
10
+ severity: 'warn' | 'error';
11
+ title: string;
12
+ }
13
+
14
+ export interface AgentBuckets {
15
+ connected: AgentInfo[];
16
+ detected: AgentInfo[];
17
+ notFound: AgentInfo[];
18
+ }
19
+
20
+ export function parseAgentsTab(tab: string | undefined): AgentsDashboardTab {
21
+ if (tab === 'mcp' || tab === 'skills') return tab;
22
+ return 'overview';
23
+ }
24
+
25
+ export function bucketAgents(agents: AgentInfo[]): AgentBuckets {
26
+ return {
27
+ connected: agents.filter((a) => a.present && a.installed),
28
+ detected: agents.filter((a) => a.present && !a.installed),
29
+ notFound: agents.filter((a) => !a.present),
30
+ };
31
+ }
32
+
33
+ export function resolveAgentStatus(agent: AgentInfo): AgentResolvedStatus {
34
+ if (agent.present && agent.installed) return 'connected';
35
+ if (agent.present) return 'detected';
36
+ return 'notFound';
37
+ }
38
+
39
+ export function capabilityForSkill(skill: SkillInfo): SkillCapability {
40
+ const text = `${skill.name} ${skill.description}`.toLowerCase();
41
+ if (text.includes('search') || text.includes('research')) return 'research';
42
+ if (text.includes('doc') || text.includes('write')) return 'docs';
43
+ if (text.includes('deploy') || text.includes('ops') || text.includes('ci')) return 'ops';
44
+ if (text.includes('memory') || text.includes('mind')) return 'memory';
45
+ return 'coding';
46
+ }
47
+
48
+ export function groupSkillsByCapability(skills: SkillInfo[]): Record<SkillCapability, SkillInfo[]> {
49
+ return {
50
+ research: skills.filter((s) => capabilityForSkill(s) === 'research'),
51
+ coding: skills.filter((s) => capabilityForSkill(s) === 'coding'),
52
+ docs: skills.filter((s) => capabilityForSkill(s) === 'docs'),
53
+ ops: skills.filter((s) => capabilityForSkill(s) === 'ops'),
54
+ memory: skills.filter((s) => capabilityForSkill(s) === 'memory'),
55
+ };
56
+ }
57
+
58
+ export function buildRiskQueue(args: {
59
+ mcpRunning: boolean;
60
+ detectedCount: number;
61
+ notFoundCount: number;
62
+ allSkillsDisabled: boolean;
63
+ }): RiskItem[] {
64
+ const items: RiskItem[] = [];
65
+ if (!args.mcpRunning) items.push({ id: 'mcp-stopped', severity: 'error', title: 'MCP server is not running' });
66
+ if (args.detectedCount > 0) items.push({ id: 'detected-unconfigured', severity: 'warn', title: `${args.detectedCount} detected agent(s) need configuration` });
67
+ if (args.notFoundCount > 0) items.push({ id: 'not-found', severity: 'warn', title: `${args.notFoundCount} agent(s) not detected on this machine` });
68
+ if (args.allSkillsDisabled) items.push({ id: 'skills-disabled', severity: 'warn', title: 'All skills are disabled' });
69
+ return items;
70
+ }
71
+
72
+ export function filterSkills(skills: SkillInfo[], query: string, source: SkillSourceFilter): SkillInfo[] {
73
+ const q = query.trim().toLowerCase();
74
+ return skills.filter((skill) => {
75
+ if (source !== 'all' && skill.source !== source) return false;
76
+ if (!q) return true;
77
+ const haystack = `${skill.name} ${skill.description}`.toLowerCase();
78
+ return haystack.includes(q);
79
+ });
80
+ }
@@ -326,8 +326,14 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
326
326
  if (!res.ok) {
327
327
  let errorMsg = `Request failed (${res.status})`;
328
328
  try {
329
- const errBody = await res.json();
330
- if (errBody.error) errorMsg = errBody.error;
329
+ const errBody = await res.json() as { error?: { message?: string } | string; message?: string };
330
+ if (typeof errBody?.error === 'string' && errBody.error.trim()) {
331
+ errorMsg = errBody.error;
332
+ } else if (typeof errBody?.error === 'object' && typeof errBody.error?.message === 'string' && errBody.error.message.trim()) {
333
+ errorMsg = errBody.error.message;
334
+ } else if (typeof errBody?.message === 'string' && errBody.message.trim()) {
335
+ errorMsg = errBody.message;
336
+ }
331
337
  } catch {}
332
338
  throw new Error(errorMsg);
333
339
  }
@@ -600,9 +606,8 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
600
606
  onChange={e => handleInputChange(e.target.value)}
601
607
  onKeyDown={handleInputKeyDown}
602
608
  placeholder={t.ask.placeholder}
603
- disabled={isLoading}
604
609
  rows={1}
605
- className="min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-2 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
610
+ className="min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-2 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none"
606
611
  />
607
612
  ) : (
608
613
  <input
@@ -613,8 +618,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
613
618
  onChange={e => handleInputChange(e.target.value)}
614
619
  onKeyDown={handleInputKeyDown}
615
620
  placeholder={t.ask.placeholder}
616
- disabled={isLoading}
617
- className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 min-w-0"
621
+ className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
618
622
  />
619
623
  )}
620
624
 
@@ -657,6 +661,11 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
657
661
  <kbd className="font-mono">ESC</kbd> {t.search.close}
658
662
  </span>
659
663
  )}
664
+ {isLoading && input.trim() && (
665
+ <span className={isPanel ? 'text-[10px] text-[var(--amber)]/80' : 'text-xs text-[var(--amber)]/80'}>
666
+ {t.ask.draftingHint}
667
+ </span>
668
+ )}
660
669
  </div>
661
670
  </>
662
671
  );
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useEffect, useState } from 'react';
5
+ import { apiFetch } from '@/lib/api';
6
+
7
+ interface ChangeSummaryPayload {
8
+ unreadCount: number;
9
+ }
10
+
11
+ export default function ChangesBanner() {
12
+ const [unreadCount, setUnreadCount] = useState(0);
13
+
14
+ useEffect(() => {
15
+ let active = true;
16
+ const fetchSummary = async () => {
17
+ try {
18
+ const summary = await apiFetch<ChangeSummaryPayload>('/api/changes?op=summary');
19
+ if (active) setUnreadCount(summary.unreadCount);
20
+ } catch {
21
+ if (active) setUnreadCount(0);
22
+ }
23
+ };
24
+ void fetchSummary();
25
+ const timer = setInterval(() => void fetchSummary(), 15_000);
26
+ return () => {
27
+ active = false;
28
+ clearInterval(timer);
29
+ };
30
+ }, []);
31
+
32
+ if (unreadCount <= 0) return null;
33
+
34
+ return (
35
+ <div className="sticky top-[52px] md:top-0 z-20 border-b bg-[var(--amber-dim)]" style={{ borderColor: 'color-mix(in srgb, var(--amber) 35%, var(--border))' }}>
36
+ <div className="px-4 md:px-6 py-2">
37
+ <div className="content-width xl:mr-[220px] flex items-center justify-between gap-3">
38
+ <p className="text-xs md:text-sm text-foreground font-display">
39
+ {unreadCount} content change{unreadCount === 1 ? '' : 's'} detected.
40
+ </p>
41
+ <Link
42
+ href="/changes"
43
+ className="text-xs md:text-sm text-[var(--amber)] hover:underline focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
44
+ >
45
+ Review changes
46
+ </Link>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ );
51
+ }